Ir para o conteúdo

Migrações

O tempest-db-js tem um sistema de migrações inspirado no Alembic (SQLAlchemy), e explicitamente diferente da "costura de SQL" de outras ferramentas: tudo flui por uma Schema IR + operações tipadas, e o SQL só nasce no renderer do dialeto. Você nunca escreve nem versiona um .sql solto.

Importe de tempest-db-js/migrations:

import {
  reflectSchema, diffSchema, generateMigration,
  MigrationRunner, type Migration,
} from "tempest-db-js/migrations";

Estado

Tudo nesta página funciona e é testado contra SQLite real (node:sqlite): reflect, diff, render, codegen, grafo DAG, runner, CLI, drift (checkDrift/introspectSqlite) e batch-mode do SQLite pra mudanças de coluna. PostgreSQL (introspecção, enum nomeado, pool) existe estruturalmente, mas ainda não é exercitado no CI — veja o Roadmap.

1. Do modelo ao IR

reflectSchema lê suas classes e produz a IR — a descrição canônica, independente de dialeto, do schema:

const target = reflectSchema([User, Post]);
// { tables: { users: { columns: {...}, primaryKey: ["id"] }, posts: {...} } }

2. Diff → operações tipadas

diffSchema(atual, alvo) compara dois IR e emite operações — nunca SQL:

import { emptySchema } from "tempest-db-js/migrations";

const ops = diffSchema(emptySchema(), target);
// [ { kind: "create_table", table: {...} }, { kind: "create_table", ... } ]

Cada operação tem um inverso conhecido (invert), o que dá down() automático.

3. Autogenerate → arquivo de migração

generateMigration transforma as operações num arquivo TS editável, com up() e um down() invertido:

const src = generateMigration({
  revision: "a1b2c3",
  downRevision: [],
  label: "create users",
  operations: ops,
});
// string TS: export const up/down, operações embutidas como dados

4. Aplicar / reverter

MigrationRunner renderiza as operações pro dialeto e executa via driver, rastreando revisões aplicadas na tabela tempest_db_js_migrations:

import { NodeSqliteDriver } from "tempest-db-js";

const driver = NodeSqliteDriver.open("app.db");
const runner = new MigrationRunner(driver, "sqlite");

runner.upgrade(migrations, new Date().toISOString()); // aplica pendentes (ordem do DAG)
runner.downgrade(migrations, 1);                       // reverte a última

Migração escrita à mão usa a fachada Op:

const migration: Migration = {
  revision: "m1",
  downRevision: [],
  up: (op) => op.createTable(reflectTable(User)),
  down: (op) => op.dropTable(reflectTable(User)),
};

5. Grafo de revisões (DAG)

downRevision é uma lista de pais — o histórico é um DAG, não uma corrente. Suporta branches paralelas e merge. topoOrder ordena pra aplicar (pais antes de filhos, determinístico); heads mostra as pontas:

import { topoOrder, heads } from "tempest-db-js/migrations";

topoOrder(migrations); // ordem de aplicação
heads(migrations);      // revisões sem filhos (avisa se > 1)

6. Mudanças de coluna no SQLite (batch-mode)

O SQLite não faz ALTER COLUMN. O tempest-db-js resolve com table-rebuild (igual ao batch mode do Alembic): a operação recreate_table cria uma tabela nova com o schema-alvo, copia as colunas comuns, e troca os nomes — preservando os dados. No PostgreSQL a mesma operação vira ALTER/ADD/DROP por coluna.

// numa migração:
up: (op) => op.recreateTable(reflectTable(UserOld), reflectTable(UserNew)),

7. Drift: o banco diverge dos modelos?

introspectSqlite lê o schema vivo do banco; checkDrift compara com os modelos e devolve uma lista de divergências (vazia = sem drift). A comparação é no nível de afinidade do SQLite, então varchar vs TEXT não é falso-positivo:

import { checkDrift } from "tempest-db-js/migrations";

const issues = checkDrift(driver, [User, Post]);
if (issues.length > 0) {
  console.error("schema drift:", issues); // ótimo como gate de CI
}

8. CLI (programática)

runMigrationCli(argv, config) despacha comandos estilo Alembic e devolve linhas + exit code (testável; um bin fino só liga em process.argv/process.exit). É async, e aceita driver sync (SQLite) ou async (PostgreSQL, MySQL):

import { runMigrationCli } from "tempest-db-js/migrations";

const config = { driver, dialect: "sqlite" as const, migrations, models: [User, Post] };
await runMigrationCli(["upgrade"], config);                 // aplica pendentes
await runMigrationCli(["upgrade", "--sql"], config);        // imprime SQL (offline)
await runMigrationCli(["downgrade", "1"], config);          // reverte
await runMigrationCli(["current"], config);                 // revisões aplicadas
await runMigrationCli(["history"], config);                 // DAG
await runMigrationCli(["heads"], config);                   // pontas
await runMigrationCli(["check"], config);                   // drift + diff (gate de CI)
await runMigrationCli(["revision", "-m", "x", "--autogenerate"], config);

replaySchema(migrations) reconstrói a IR "atual" sem banco — é o que o --autogenerate compara com os modelos.

PostgreSQL pelo CLI

O mesmo config, com um driver async — nada mais muda:

// tempest-db.config.mjs
import { defineMigrationConfig } from "tempest-db-js/migrations";
import { createEngine } from "tempest-db-js";

const engine = createEngine("postgresql://app@localhost/app");

export default defineMigrationConfig({
  driver: engine.driver,                 // (1)!
  dialect: "postgresql",
  migrations,
  models: [User, Post],
});
  1. Qualquer objeto que satisfaça AsyncDriver serve.

Sync e async pelo mesmo caminho

O CLI adapta o driver com toAsyncDriver e roda tudo no AsyncMigrationRunner. Um driver sync e um async têm a mesma forma — a diferença só aparece no valor de retorno, e await normaliza os dois. Por isso não existe flag async no config para você errar.

check por dialeto

checkDriftAsync roteia: SQLite via introspectSqliteAsync, PostgreSQL via information_schema, e MySQL devolve uma mensagem explícita de "não implementado" — a introspecção MySQL ainda não existe.

9. Backup e restore

O passo que todo runbook pede antes de rodar migração em produção:

tempest-db backup out/app-2026-09-05.dump --url "$DATABASE_URL"
tempest-db restore out/app-2026-09-05.dump --url "$DATABASE_URL"

Sem --url, usa DATABASE_URL. Também dá para chamar programaticamente (backupDatabase(url, file) / restoreDatabase(url, file)).

Banco Backup Restore
PostgreSQL (.dump) pg_dump --format=custom pg_restore
PostgreSQL (.sql) pg_dump plano psql --file
SQLite VACUUM INTO cópia do arquivo

A extensão escolhe o formato, então os dois comandos não podem discordar sobre o arquivo.

No SQLite, copiar o arquivo não é backup

Com WAL ligado, o .db sozinho não é o banco inteiro — parte dos dados está no -wal. VACUUM INTO produz um arquivo consistente mesmo com outra conexão escrevendo, e é por isso que ele é usado aqui em vez de cp.

A senha vai por PGPASSWORD, nunca em argv

Qualquer processo da máquina lê a linha de comando alheia. A senha da URL é passada por variável de ambiente ao processo filho.

Sufixo de driver é removido

postgresql+asyncpg://… é URL de serviço Python; o pg_dump não conhece esse esquema. O sufixo sai antes de montar o comando.

Ferramenta ausente no PATH vira erro nomeado (BackupToolMissing), não stack trace de spawn. backup/restore são despachados antes do carregamento do config: um banco que ainda não migra é exatamente o que alguém precisa dumpar.

Recap

  • reflectSchema(models) → IR; diffSchema(atual, alvo) → operações tipadas.
  • generateMigration(...) → arquivo TS editável com up()/down() invertido.
  • MigrationRunner.upgrade/downgrade aplica/reverte de verdade, com version table; o CLI usa o AsyncMigrationRunner e roda nos 3 bancos.
  • Grafo DAG (topoOrder/heads) suporta branch/merge.
  • SQL só no renderer do dialeto — nunca um .sql solto.