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/node2) 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Criando migrations: passo a passo
1) Criar uma migration
npm run db:make:migration create_users_and_accounts2) 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:migrateBoas 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 columne 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_data2) 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:seedArmadilhas 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) => ...)fazcommitautomaticamente se não houver erro; se lançar erro, fazrollback.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ível | Risco mitigado | Custo/impacto |
|---|---|---|
| READ COMMITTED | Evita ler dados não confirmados | Boa performance; ainda pode haver leituras não repetíveis |
| REPEATABLE READ | Evita leituras não repetíveis | Mais conflitos/abortos em alta concorrência |
| SERIALIZABLE | Simula execução serial | Maior 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 UPDATEnessa 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 NULLdepois.
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) gerandodist. - 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:statusVerificaçã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.