import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { identityShimEnabled } from "@/config/env"; import { EXPECTED_MIGRATIONS, isShimMigration } from "./migration-manifest"; import type { Db } from "./types"; /** * Executor de migrations. Lê o diretório `migrations/`, por isso só é usado * pelos scripts operacionais e pelo banco local de development — nunca por uma * rota. Quem precisa saber o estado do schema em runtime usa * `migration-manifest.ts`, que só consulta o banco. * * O caminho é estaticamente limitado a `/migrations`: montar o caminho a * partir de uma variável fazia o Next tracear o projeto inteiro para dentro da * saída `standalone`, levando docs, testes e fontes para a imagem. */ const MIGRATIONS_FOLDER = "migrations"; export type MigrateOptions = { /** Aplica o shim de identidade. Padrão: `ADAPTER_APPLY_IDENTITY_SHIM`. */ applyIdentityShim?: boolean; }; export function migrationsDir(): string { return path.join(process.cwd(), MIGRATIONS_FOLDER); } export async function listMigrationFiles(): Promise { const entries = await readdir(path.join(process.cwd(), MIGRATIONS_FOLDER)); return entries.filter((file) => file.endsWith(".sql")).sort(); } /** * O manifesto estático e o diretório precisam contar a mesma história. Um * arquivo novo sem entrada no manifesto passaria em branco pelo health check. */ export function assertManifestMatches(files: string[]): void { const expected: string[] = [...EXPECTED_MIGRATIONS]; const extra = files.filter((file) => !expected.includes(file)); const missing = expected.filter((file) => !files.includes(file)); if (extra.length === 0 && missing.length === 0) return; const parts = [ extra.length ? `fora do manifesto: ${extra.join(", ")}` : "", missing.length ? `ausentes no diretório: ${missing.join(", ")}` : "", ].filter(Boolean); throw new Error( `migrations/ e EXPECTED_MIGRATIONS divergiram (${parts.join("; ")}). Atualize src/infra/db/migration-manifest.ts.`, ); } export async function migrate(db: Db, options: MigrateOptions = {}): Promise { const applyShim = options.applyIdentityShim ?? identityShimEnabled(); await db.exec(` create table if not exists public.schema_migrations ( id text primary key, applied_at timestamptz not null default now() ); `); const files = await listMigrationFiles(); assertManifestMatches(files); const appliedRows = await db.withService((query) => query.query<{ id: string }>("select id from public.schema_migrations")); const applied = new Set(appliedRows.map((row) => row.id)); const ran: string[] = []; for (const file of files) { if (applied.has(file)) continue; if (isShimMigration(file) && !applyShim) continue; const sql = await readFile(path.join(process.cwd(), MIGRATIONS_FOLDER, file), "utf8"); await db.exec(sql); await db.withService((query) => query.query("insert into public.schema_migrations (id) values ($1)", [file])); ran.push(file); } return ran; } export { EXPECTED_MIGRATIONS, SHIM_MIGRATIONS, isShimMigration, pendingMigrations } from "./migration-manifest";