Pular para o conteúdo principal

Migration

Quando usar: toda mudança de schema. Durante uma troca de release convivem a release N e a N-1 — as duas no mesmo banco. Por isso a regra é expand-only: release nova só acrescenta.

Expand-only na prática​

PermitidoProibido
CREATE TABLE IF NOT EXISTSDROP TABLE, DROP COLUMN
ALTER TABLE … ADD COLUMN … NULLALTER TABLE … MODIFY … NOT NULL numa coluna que a N-1 escreve
Índice novoRenomear coluna ou tabela
Tabela nova para o recurso novoReescrever dado da N-1 na mesma migration

O rollback é a release N-1 rodando sobre o schema novo: ela ignora a coluna a mais. Por isso a coluna nova é anulável e o código novo tolera o valor antigo (o default vive na aplicação, não no schema).

O arquivo e a lista​

nns/internal/store/migrations/0001_sample_items.up.sql:

-- sample@0.1.0 — itens do módulo sample, tenant-scoped: toda consulta e toda
-- mutação filtram por tenant_id (o tenant vem do ctx VERIFICADO, nunca do
-- browser). O nome é único por tenant sem diferenciar maiúsculas (collation
-- ai_ci). A varredura é uma Action (nn_actions, do SDK): a tabela guarda só a
-- última (scan_action_id) e o resultado registrado pelo Runner.
--
-- Expand-only (CONTRACTS §3.3.4): release nova só ACRESCENTA; o rollback
-- nunca roda um "down". Por isso o CREATE TABLE é IF NOT EXISTS e a coluna
-- nova entra num arquivo NOVO, anulável.
CREATE TABLE IF NOT EXISTS sample_items (
tenant_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
item_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
name VARCHAR(80) NOT NULL,
description VARCHAR(500) NOT NULL,
created_by VARCHAR(300) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_release_id VARCHAR(105) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
scan_action_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
scan_status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'none',
scan_engine VARCHAR(40) CHARACTER SET ascii COLLATE ascii_bin NULL,
scan_findings JSON NULL,
scan_score TINYINT UNSIGNED NULL,
scanned_at DATETIME(3) NULL,
scanned_by VARCHAR(105) CHARACTER SET ascii COLLATE ascii_bin NULL,
PRIMARY KEY (tenant_id, item_id),
UNIQUE KEY uq_sample_items_name (tenant_id, name),
KEY ix_sample_items_created (tenant_id, created_at, item_id),
KEY ix_sample_items_scan (scan_status, updated_at),
CONSTRAINT ck_sample_items_scan_status CHECK (scan_status IN ('none', 'pending', 'running', 'succeeded', 'failed'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

O nome do arquivo segue <NNNN>_<assunto>.up.sql. O MANIFEST é a lista fechada do que a Source publica — o migrador lê por lista, nunca listando o classpath:

java/src/main/resources/br/com/nnumbers/console/sample/store/migrations/MANIFEST

# As migrations da Source "sample" (lidas do lugar: nns/internal/store/migrations, pelo <resources> do pom).
# Release nova só ACRESCENTA arquivo; SampleMigrationsTest compara esta lista com o diretório.
0001_sample_items.up.sql

Os arquivos não são copiados para dentro de src/: o pom.xml os lê do lugar e a lista do classpath é o MANIFEST:

<resource>
<directory>${nn.sample.root}/nns/internal/store/migrations</directory>
<targetPath>br/com/nnumbers/console/sample/store/migrations</targetPath>
<filtering>false</filtering>
<includes>
<include>*.sql</include>
</includes>
</resource>

A ordem das Sources​

java/src/main/java/.../store/SampleMigrations.java:

public static Migrator migrator() {
return Migrator.of(
MigrationSource.sdk(Migrator.SOURCE_REPLAY),
MigrationSource.sdk(Migrator.SOURCE_AUDIT),
MigrationSource.sdk(Migrator.SOURCE_IDEMPOTENCY),
MigrationSource.sdk(Migrator.SOURCE_ACTION),
MigrationSource.ofClasspath(SOURCE, SampleMigrations.class.getClassLoader(), RESOURCES));
}

As tabelas do SDK (replay de token, outbox de audit, idempotência, Actions) vêm antes das suas: o que o runtime usa para funcionar precisa existir antes do primeiro uso.

O nome da Source no ledger é o service.id (sample), e a versão é a ordem do arquivo (0001 → 1). O mesmo migrador serve o Job migrate e o check schema do /ready.

Os grants da conta de runtime​

A conta de runtime recebe DML por tabela, e nada de DDL:

public static final List<TableGrant> GRANTS =
List.of(TableGrant.of("sample_items", "SELECT", "INSERT", "UPDATE", "DELETE"));

A conta de migração (outro slot, db-migrator) é a única com DDL, e só existe durante o Job: se ela vazar, o dano é limitado ao schema; se a de runtime vazar, o dano é limitado às linhas do módulo.

Checando o efeito​

@Test
void migrateIsIdempotent() {
// O Job da release roda de novo a cada deploy: a segunda passada aplica ZERO migration.
assertThat(SampleMigrations.migrator().apply(CTX, db)).isZero();
}

@Test
void ledgerRecordsThePublishedMigrationWithItsChecksum() {
List<String> rows =
db.query(CTX,
"SELECT source, version, checksum FROM nn_schema_migrations WHERE source = ? ORDER BY version",
r -> r.string(1) + "/" + r.string(2) + "/" + r.string(3), "sample");
assertThat(rows).hasSize(1).allSatisfy(row -> assertThat(row).startsWith("sample/"));
}

E, em JDK puro, o hash congelado do arquivo publicado — mudar um .sql já aplicado quebra o build em vez de quebrar o deploy:

private static final String FROZEN_0001 =
"1ef8e05cd45468822d5df9d984a7054d9dca1208e6d541029cc5d12873d249f5";

@Test
void thePublishedMigrationIsFrozen() throws Exception {
assertThat(sha256(resource("0001_sample_items.up.sql"))).isEqualTo(FROZEN_0001);
}

@Test
void manifestListsExactlyTheRepositoryDirectory() throws Exception {
// O migrador lê por LISTA, nunca listando o classpath: um arquivo esquecido no MANIFEST não
// seria aplicado, e este teste é quem avisa.
assertThat(manifest).isEqualTo(onDisk);
}

Erros comuns​

  • Editar um arquivo já publicado — o ledger guarda o checksum e o migrador recusa: a release não sobe. Correção de schema é arquivo novo.
  • Esquecer o MANIFEST — o arquivo não é aplicado, e o erro só aparece em produção. O teste que compara o MANIFEST com o diretório pega isso no build.
  • Coluna nova NOT NULL sem default — a release N-1, que ainda está no ar, quebra ao inserir.
  • Migration que apaga ou renomeia — o rollback deixa de ser possível, e a N-1 passa a falhar.
  • DDL na conta de runtime — o contrato é DML por tabela; DDL é do Job.
  • Escrever SQL gigante numa migration sem IF NOT EXISTS — o migrador é idempotente por ledger, mas um re-run manual em banco que já tem a tabela precisa poder passar.
  • Contar com um "down" — não existe: o rollback é a release anterior sobre o schema novo.
  • Misturar dado e schema — dado de negócio não entra em migration.