Migrations, seeds e transações: consistência de dados no back-end Node.js

Capítulo 12

Tempo estimado de leitura: 11 minutos

+ Exercício

O que são migrations, seeds e transações (e por que importam)

Migrations são scripts versionados que descrevem mudanças no schema do banco (criar/alterar tabelas, índices, constraints). Elas permitem que o schema evolua de forma rastreável e reproduzível entre ambientes.

Seeds são rotinas para inserir dados de apoio (ex.: perfis, permissões, status, catálogos) em ambientes de desenvolvimento e teste, garantindo que o sistema “suba” com dados mínimos consistentes.

Transações agrupam múltiplas operações de escrita/leitura para que sejam atômicas: ou tudo é confirmado (commit) ou tudo é desfeito (rollback). Elas são essenciais quando uma operação de negócio envolve várias tabelas e não pode ficar em estado parcial.

Estratégia recomendada de versionamento de schema

  • Uma migration por mudança lógica: evite “mega migrations” com muitas alterações não relacionadas.
  • Imutabilidade: migrations já aplicadas em produção não devem ser editadas; crie uma nova migration para corrigir.
  • Reversibilidade: sempre que possível, implemente down (rollback da migration). Em casos irreversíveis (ex.: drop de coluna com dados), documente e proteja com validações.
  • Separar schema de dados: migrations para estrutura; seeds para dados de apoio. Evite inserir dados de negócio em migrations (exceto quando necessário para compatibilidade).

Configuração prática: migrations e seeds com Knex + TypeScript

O exemplo abaixo usa PostgreSQL, knex e TypeScript. Adapte a conexão conforme seu ambiente.

1) Dependências

npm i knex pg
npm i -D ts-node typescript @types/node

2) Estrutura de pastas sugerida

src/
  db/
    knexfile.ts
    client.ts
  migrations/
  seeds/
  modules/
    ...

3) knexfile.ts

import type { Knex } from "knex";

const config: Record<string, Knex.Config> = {
  development: {
    client: "pg",
    connection: process.env.DATABASE_URL,
    migrations: {
      directory: "./src/migrations",
      extension: "ts",
      tableName: "knex_migrations",
    },
    seeds: {
      directory: "./src/seeds",
      extension: "ts",
    },
    pool: { min: 2, max: 10 },
  },
  test: {
    client: "pg",
    connection: process.env.TEST_DATABASE_URL,
    migrations: {
      directory: "./src/migrations",
      extension: "ts",
      tableName: "knex_migrations",
    },
    seeds: {
      directory: "./src/seeds",
      extension: "ts",
    },
  },
  production: {
    client: "pg",
    connection: process.env.DATABASE_URL,
    migrations: {
      directory: "./dist/migrations",
      tableName: "knex_migrations",
    },
  },
};

export default config;

4) Cliente do banco

import knex, { Knex } from "knex";
import config from "./knexfile";

const env = process.env.NODE_ENV ?? "development";
export const db: Knex = knex(config[env]);

5) Scripts no package.json

{
  "scripts": {
    "db:migrate": "knex --knexfile src/db/knexfile.ts --esm migrate:latest",
    "db:rollback": "knex --knexfile src/db/knexfile.ts --esm migrate:rollback",
    "db:make:migration": "knex --knexfile src/db/knexfile.ts --esm migrate:make",
    "db:seed": "knex --knexfile src/db/knexfile.ts --esm seed:run",
    "db:make:seed": "knex --knexfile src/db/knexfile.ts --esm seed:make"
  }
}

Observação: dependendo do seu setup (CommonJS/ESM), você pode ajustar flags e/ou usar um wrapper com ts-node/register. O objetivo aqui é mostrar a organização e o fluxo.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Criando migrations: passo a passo

1) Criar uma migration

npm run db:make:migration create_users_and_accounts

2) Exemplo de migration com constraints e índices

import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
  await knex.schema.createTable("users", (t) => {
    t.uuid("id").primary();
    t.string("email", 255).notNullable().unique();
    t.string("name", 120).notNullable();
    t.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
  });

  await knex.schema.createTable("accounts", (t) => {
    t.uuid("id").primary();
    t.uuid("user_id").notNullable().references("id").inTable("users").onDelete("CASCADE");
    t.string("type", 30).notNullable();
    t.decimal("balance", 14, 2).notNullable().defaultTo(0);
    t.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());

    t.index(["user_id"]);
  });
}

export async function down(knex: Knex): Promise<void> {
  await knex.schema.dropTableIfExists("accounts");
  await knex.schema.dropTableIfExists("users");
}

3) Executar migrations

npm run db:migrate

Boas práticas importantes ao escrever migrations:

  • Crie índices conscientemente: índices aceleram leitura, mas custam em escrita.
  • Use constraints (unique, foreign key, not null) para garantir integridade no banco, não só na aplicação.
  • Evite operações destrutivas sem plano: drop column e mudanças de tipo podem exigir migração em etapas.

Seeds: dados de apoio para dev/test

1) Criar seed

npm run db:make:seed seed_reference_data

2) Exemplo de seed idempotente (upsert)

Seeds devem ser reexecutáveis sem duplicar dados. Em PostgreSQL, use onConflict.

import type { Knex } from "knex";

export async function seed(knex: Knex): Promise<void> {
  await knex("roles")
    .insert([
      { id: "admin", name: "Admin" },
      { id: "user", name: "User" },
    ])
    .onConflict("id")
    .merge();

  await knex("account_types")
    .insert([
      { id: "checking", name: "Checking" },
      { id: "savings", name: "Savings" },
    ])
    .onConflict("id")
    .merge();
}

Se o seu banco não suportar upsert da mesma forma, a alternativa é: consultar antes e inserir apenas o que falta, ou limpar tabelas de referência em ambiente de teste (com cuidado).

3) Executar seeds

npm run db:seed

Armadilhas comuns em seeds

  • Seeds não determinísticos: evitar gerar valores aleatórios sem fixar uma seed de randomização, pois testes podem ficar instáveis.
  • Dependência de ordem: se uma seed depende de outra, explicite a ordem (ou una em uma seed única para dados de referência).
  • Rodar seeds em produção: em geral, limite seeds a dev/test. Para dados obrigatórios em produção (ex.: permissões), trate como “dados de referência versionados” com rotinas controladas e idempotentes.

Transações: garantindo atomicidade em operações reais

Uma transação deve envolver tudo o que precisa ser consistente. Exemplo: transferência entre contas. Você precisa: (1) registrar a transferência, (2) debitar uma conta, (3) creditar outra. Se qualquer etapa falhar, nada deve persistir.

Caso realista: transferência com lock e validações

Objetivos:

  • Evitar partial writes (registro criado, mas saldo não atualizado).
  • Evitar race conditions (duas transferências simultâneas consumindo o mesmo saldo).
  • Garantir rollback automático em erro.
import { db } from "../db/client";

type TransferInput = {
  fromAccountId: string;
  toAccountId: string;
  amount: number;
};

export async function transfer({ fromAccountId, toAccountId, amount }: TransferInput) {
  if (amount <= 0) throw new Error("amount must be positive");
  if (fromAccountId === toAccountId) throw new Error("cannot transfer to same account");

  return db.transaction(async (trx) => {
    // Lock das linhas para evitar concorrência no saldo
    const from = await trx("accounts")
      .where({ id: fromAccountId })
      .forUpdate()
      .first();

    const to = await trx("accounts")
      .where({ id: toAccountId })
      .forUpdate()
      .first();

    if (!from || !to) throw new Error("account not found");

    const fromBalance = Number(from.balance);
    if (fromBalance < amount) throw new Error("insufficient funds");

    // 1) Registrar a transferência
    const [transferRow] = await trx("transfers")
      .insert({
        id: trx.raw("gen_random_uuid()"),
        from_account_id: fromAccountId,
        to_account_id: toAccountId,
        amount,
        created_at: trx.fn.now(),
      })
      .returning(["id", "created_at"]);

    // 2) Atualizar saldos
    await trx("accounts")
      .where({ id: fromAccountId })
      .update({ balance: trx.raw("balance - ?", [amount]) });

    await trx("accounts")
      .where({ id: toAccountId })
      .update({ balance: trx.raw("balance + ?", [amount]) });

    return { transferId: transferRow.id, createdAt: transferRow.created_at };
  });
}

Pontos-chave do exemplo:

  • db.transaction(async (trx) => ...) faz commit automaticamente se não houver erro; se lançar erro, faz rollback.
  • forUpdate() (PostgreSQL/MySQL) bloqueia as linhas lidas até o fim da transação, reduzindo risco de inconsistência por concorrência.
  • Atualização de saldo usa expressão no banco (balance - ?) para evitar “read-modify-write” inseguro.

Tratamento correto de rollback e propagação de erro

O padrão mais seguro é: lançar erro dentro da transação e deixar o driver/Knex fazer rollback. Evite capturar e “engolir” exceções dentro do callback, pois isso pode levar a commits indevidos.

return db.transaction(async (trx) => {
  try {
    // ... operações
  } catch (err) {
    // NÃO finalize com return silencioso.
    // Re-lance para garantir rollback.
    throw err;
  }
});

Transações com etapas externas: o que não colocar dentro

Evite manter transação aberta enquanto faz chamadas externas (HTTP, filas, e-mail). Isso aumenta tempo de lock e risco de deadlock/timeouts. Estratégias comuns:

  • Outbox pattern: gravar um evento em uma tabela “outbox” dentro da transação e publicar depois.
  • Compensação: se algo externo falhar após commit, executar uma operação compensatória (ex.: estornar).

Isolamento e concorrência: entendendo o comportamento

O nível de isolamento define como uma transação enxerga mudanças concorrentes. Em PostgreSQL, o padrão é READ COMMITTED, que geralmente é suficiente com locks pontuais (FOR UPDATE) para operações críticas.

NívelRisco mitigadoCusto/impacto
READ COMMITTEDEvita ler dados não confirmadosBoa performance; ainda pode haver leituras não repetíveis
REPEATABLE READEvita leituras não repetíveisMais conflitos/abortos em alta concorrência
SERIALIZABLESimula execução serialMaior chance de abortos; exige retry

Quando considerar retry automático

Em níveis mais altos (ou em cenários com locks), você pode receber erros de serialização/deadlock. Para operações idempotentes e bem definidas, implemente retry com limite.

async function withRetry<T>(fn: () => Promise<T>, max = 3) {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (e: any) {
      attempt++;
      const message = String(e?.message ?? "");
      const retryable = message.includes("deadlock") || message.includes("could not serialize");
      if (!retryable || attempt >= max) throw e;
      await new Promise((r) => setTimeout(r, 50 * attempt));
    }
  }
}

// uso
// await withRetry(() => db.transaction(async (trx) => { ... }));

Armadilhas comuns e como evitar

Deadlocks

Deadlock ocorre quando duas transações seguram locks que a outra precisa. Prevenção prática:

  • Ordem consistente de locks: sempre bloqueie recursos na mesma ordem (ex.: ordenar IDs e dar FOR UPDATE nessa ordem).
  • Transações curtas: faça apenas o necessário dentro da transação.
  • Índices adequados: updates sem índice podem escanear muitas linhas e ampliar locks.
// Exemplo: lock em ordem consistente
const ids = [fromAccountId, toAccountId].sort();
const rows = await trx("accounts").whereIn("id", ids).forUpdate();

Partial writes por falta de transação

Se você faz múltiplos insert/update fora de transação, qualquer falha intermediária deixa o banco em estado parcial. Regra prática: se a operação de negócio “precisa ser tudo ou nada”, use transação.

Transação “vazada” (não usar o trx em todas as queries)

Um erro frequente é iniciar transação e executar parte das queries no cliente global (db) em vez do trx. Isso quebra atomicidade.

// Errado: mistura db e trx
await db("transfers").insert(...); // fora da transação
await trx("accounts").update(...);

// Certo: tudo via trx
await trx("transfers").insert(...);
await trx("accounts").update(...);

Migrations perigosas em tabelas grandes

Alterações como ALTER TABLE ... ADD COLUMN ... DEFAULT ... NOT NULL podem reescrever a tabela e causar lock prolongado. Estratégia em etapas (exemplo):

  • Adicionar coluna nullable sem default.
  • Backfill em batches (job/rotina controlada).
  • Adicionar constraint NOT NULL depois.

Rotinas padrão para pipeline de deploy e verificação em produção

Princípios

  • Build imutável: em produção, rode migrations a partir do artefato buildado (ex.: dist/migrations), não do TypeScript fonte.
  • Fail fast: se migration falhar, o deploy deve falhar e não subir a nova versão.
  • Compatibilidade em duas fases: quando necessário, faça deploy “expand/contract”: primeiro adiciona campos/tabelas compatíveis, depois muda a aplicação, e por fim remove o legado em outra janela.

Checklist de pipeline (exemplo prático)

  • Step 1: Rodar testes e lint.
  • Step 2: Build (tsc) gerando dist.
  • Step 3: Executar migrations no banco alvo antes de iniciar a nova versão do app.
  • Step 4: Health check do app.

Comandos típicos

# build
npm run build

# aplicar migrations em produção (apontando para dist)
NODE_ENV=production knex --knexfile dist/db/knexfile.js migrate:latest

# checar status (útil para auditoria)
NODE_ENV=production knex --knexfile dist/db/knexfile.js migrate:status

Verificação de migrações pendentes no boot (opcional e controlado)

Em alguns cenários, você pode validar no startup se o schema está no nível esperado e recusar subir se estiver defasado. Isso evita rodar a aplicação com schema incompatível.

import { db } from "./db/client";

export async function assertNoPendingMigrations() {
  const hasTable = await db.schema.hasTable("knex_migrations");
  if (!hasTable) throw new Error("migrations table not found");

  const pending = await db.migrate.list();
  const pendingNames = pending[1];
  if (pendingNames.length > 0) {
    throw new Error(`pending migrations: ${pendingNames.join(", ")}`);
  }
}

Observação: rodar migrations automaticamente no boot pode ser perigoso em ambientes com múltiplas instâncias subindo ao mesmo tempo. Prefira executar migrations como etapa única do pipeline (job/runner) e usar o boot apenas para verificação.

Agora responda o exercício sobre o conteúdo:

Em uma operação de negócio que envolve várias atualizações no banco (por exemplo, registrar uma transferência e atualizar saldos), qual prática garante que o banco não fique em estado parcial se ocorrer um erro no meio do processo?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Transações garantem atomicidade: ou tudo confirma (commit) ou tudo desfaz (rollback). Para não quebrar a consistência, todas as queries devem usar o trx, e erros devem ser lançados para acionar o rollback.

Próximo capitúlo

Arquitetura de pastas e camadas no Node.js com Express e TypeScript

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
75%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.