#!/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} knownDuplicates versões com duplicata reconhecida (ex.: "041") * @param {Set} 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();