162 lines
7.0 KiB
JavaScript
162 lines
7.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/check/check-migration-numbering.mjs
|
|
// Gate de numeração de migrations: protege src/lib/db/migrations/ contra regressões
|
|
// de numeração. WHY: um incidente destrutivo aconteceu DUAS VEZES — um `git rm` de
|
|
// uma migration duplicada durante um merge apagou uma migration REAL da release
|
|
// (094/095 em #3365/#3371). Este gate cria uma ligação de CI entre o disco e as
|
|
// anomalias já reconhecidas em migrationRunner.ts, falhando em QUALQUER:
|
|
// - nome de arquivo sem prefixo numérico zero-padded (NNN_*.sql);
|
|
// - prefixo de versão DUPLICADO no disco (exceto duplicatas já reconhecidas);
|
|
// - NOVO gap inexplicado na sequência (gaps conhecidos 026/055 são congelados).
|
|
// As anomalias conhecidas são derivadas das listas de migrationRunner.ts
|
|
// (LEGACY_VERSION_SLOT_MIGRATIONS / SUPERSEDED_DUPLICATE_MIGRATIONS) + a auditoria
|
|
// de gaps de sequência. NÃO adicione novos itens sem justificativa — esse é o ponto.
|
|
// Stale-enforcement (6A.3): entrada em KNOWN_GAPS / KNOWN_DUPLICATE_VERSIONS que não
|
|
// suprime nenhuma anomalia real → gate falha com instrução de remoção.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { assertNoStale } from "./lib/allowlist.mjs";
|
|
|
|
const cwd = process.cwd();
|
|
const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations");
|
|
|
|
// Convenção de nome: NNN_descricao.sql (prefixo numérico zero-padded de >= 3 dígitos).
|
|
// Mesmo regex usado pelo runner de produção (migrationRunner.ts ~linha 282).
|
|
const MIGRATION_NAME_RE = /^(\d{3,})_(.+)\.sql$/;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ALLOWLIST 1 — duplicatas de versão CONHECIDAS.
|
|
// Fonte: src/lib/db/migrationRunner.ts → SUPERSEDED_DUPLICATE_MIGRATIONS (~L188).
|
|
// O runner já aceita estes slots de versão reutilizados (a migration "renomeada"
|
|
// foi promovida para um número novo, e o slot antigo é tolerado). Adicionar aqui
|
|
// SOMENTE se houver um arquivo físico duplicado no disco (stale-enforcement 6A.3
|
|
// detecta entradas sem duplicata física viva e força remoção).
|
|
// ---------------------------------------------------------------------------
|
|
export const KNOWN_DUPLICATE_VERSIONS = new Set([
|
|
// "041" was removed: 041_session_account_affinity.sql no longer exists on disk
|
|
// (only 041_compression_receipts.sql remains), so no physical duplicate is present.
|
|
// The SUPERSEDED_DUPLICATE_MIGRATIONS entry in migrationRunner.ts handles the runner
|
|
// compatibility at runtime without needing an allowlist here. (#6A.3 stale cleanup)
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
|
|
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
|
|
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
|
|
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
|
|
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
|
|
// ---------------------------------------------------------------------------
|
|
export const KNOWN_GAPS = new Set(["026", "055"]);
|
|
|
|
function pad3(n) {
|
|
return String(n).padStart(3, "0");
|
|
}
|
|
|
|
/**
|
|
* Função pura — detecta anomalias de numeração de migrations.
|
|
*
|
|
* @param {string[]} filenames nomes de arquivo (basename) em src/lib/db/migrations/
|
|
* @param {Set<string>} knownDuplicates versões com duplicata reconhecida (ex.: "041")
|
|
* @param {Set<string>} knownGaps gaps de sequência reconhecidos (ex.: "026")
|
|
* @returns {{ duplicates: Array<{version:string,names:string[]}>, gaps: string[], badNames: string[] }}
|
|
*/
|
|
export function findMigrationAnomalies(filenames, knownDuplicates, knownGaps) {
|
|
const dups = knownDuplicates || new Set();
|
|
const gapsAllow = knownGaps || new Set();
|
|
|
|
const badNames = [];
|
|
const byVersion = new Map();
|
|
|
|
for (const filename of filenames) {
|
|
if (!filename.endsWith(".sql")) continue;
|
|
const match = filename.match(MIGRATION_NAME_RE);
|
|
if (!match) {
|
|
badNames.push(filename);
|
|
continue;
|
|
}
|
|
const version = match[1];
|
|
if (!byVersion.has(version)) byVersion.set(version, []);
|
|
byVersion.get(version).push(filename);
|
|
}
|
|
|
|
// Duplicatas: dois arquivos físicos com o mesmo prefixo, exceto os reconhecidos.
|
|
const duplicates = [];
|
|
for (const [version, names] of byVersion.entries()) {
|
|
if (names.length <= 1) continue;
|
|
if (dups.has(version)) continue;
|
|
duplicates.push({ version, names: [...names].sort() });
|
|
}
|
|
duplicates.sort((a, b) => a.version.localeCompare(b.version));
|
|
|
|
// Gaps: buracos na sequência min..max que não estão na allowlist.
|
|
const versions = [...byVersion.keys()].map((v) => parseInt(v, 10)).sort((a, b) => a - b);
|
|
const gaps = [];
|
|
if (versions.length > 0) {
|
|
const min = versions[0];
|
|
const max = versions[versions.length - 1];
|
|
const present = new Set(versions);
|
|
for (let n = min + 1; n < max; n++) {
|
|
if (present.has(n)) continue;
|
|
const padded = pad3(n);
|
|
if (gapsAllow.has(padded)) continue;
|
|
gaps.push(padded);
|
|
}
|
|
}
|
|
|
|
return { duplicates, gaps, badNames };
|
|
}
|
|
|
|
function listMigrationFilenames() {
|
|
if (!fs.existsSync(MIGRATIONS_DIR)) return [];
|
|
return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
|
|
}
|
|
|
|
function main() {
|
|
const filenames = listMigrationFilenames();
|
|
|
|
// Compute raw anomalies WITHOUT allowlists — needed for stale-enforcement (6A.3).
|
|
const raw = findMigrationAnomalies(filenames, new Set(), new Set());
|
|
const liveGaps = raw.gaps;
|
|
const liveDupVersions = raw.duplicates.map((d) => d.version);
|
|
assertNoStale(KNOWN_GAPS, liveGaps, "check-migration-numbering:gaps");
|
|
assertNoStale(KNOWN_DUPLICATE_VERSIONS, liveDupVersions, "check-migration-numbering:duplicates");
|
|
|
|
const { duplicates, gaps, badNames } = findMigrationAnomalies(
|
|
filenames,
|
|
KNOWN_DUPLICATE_VERSIONS,
|
|
KNOWN_GAPS
|
|
);
|
|
|
|
const problems = [];
|
|
for (const b of badNames) {
|
|
problems.push(` ✗ nome inválido (esperado NNN_descricao.sql): ${b}`);
|
|
}
|
|
for (const d of duplicates) {
|
|
problems.push(` ✗ prefixo de versão duplicado ${d.version}: [${d.names.join(", ")}]`);
|
|
}
|
|
for (const g of gaps) {
|
|
problems.push(` ✗ gap inexplicado na sequência: faltando ${g}`);
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
console.error(
|
|
`[check-migration-numbering] ${problems.length} anomalia(s) de numeração:\n` +
|
|
problems.join("\n") +
|
|
`\n → renomeie o arquivo colidente, preencha o gap, ou — se for legítimo — ` +
|
|
`adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` +
|
|
`justificativa rastreável a src/lib/db/migrationRunner.ts.`
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
if (!process.exitCode) {
|
|
console.log(
|
|
`[check-migration-numbering] OK (${filenames.length} migrations, ` +
|
|
`${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|