import { createHash, randomUUID } from "node:crypto"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import pg from "pg"; /** * Spielt die Migrationen aus `prisma/migrations/` ein – im Betrieb statt `prisma migrate deploy`, * damit weder Docker-Image noch LXC das Prisma-CLI samt Schema-Engine brauchen. * * Kompatibel zu Prisma: dieselbe Tabelle `_prisma_migrations`, Prüfsumme = SHA-256 der * migration.sql. In der Entwicklung bleibt `prisma migrate dev` also benutzbar, und beide * erkennen, was schon eingespielt ist. * * Jede Migration läuft in einer eigenen Transaktion; eine Sperre (`pg_advisory_lock`) verhindert, * dass app und worker gleichzeitig migrieren. Eine eingespielte Migration, deren Datei sich * geändert hat, ist ein Fehler – dann stimmt der Codestand nicht zur Datenbank. */ const SPERRE = 724_196_331; // beliebig, aber fest: „proxdeploy-migration“ export type MigrationsErgebnis = { eingespielt: string[]; vorhanden: number }; export async function migrieren( datenbankUrl: string, ordner: string, log: (text: string) => void = () => {}, ): Promise { const migrationen = readdirSync(ordner, { withFileTypes: true }) .filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)) .map((e) => e.name) .sort(); const client = new pg.Client({ connectionString: datenbankUrl }); await client.connect(); try { await client.query("SELECT pg_advisory_lock($1)", [SPERRE]); await client.query(`CREATE TABLE IF NOT EXISTS "_prisma_migrations" ( "id" VARCHAR(36) PRIMARY KEY NOT NULL, "checksum" VARCHAR(64) NOT NULL, "finished_at" TIMESTAMPTZ, "migration_name" VARCHAR(255) NOT NULL, "logs" TEXT, "rolled_back_at" TIMESTAMPTZ, "started_at" TIMESTAMPTZ NOT NULL DEFAULT now(), "applied_steps_count" INTEGER NOT NULL DEFAULT 0 )`); const { rows } = await client.query<{ migration_name: string; checksum: string; finished_at: Date | null; }>(`SELECT migration_name, checksum, finished_at FROM "_prisma_migrations" WHERE rolled_back_at IS NULL`); const bekannt = new Map(rows.map((r) => [r.migration_name, r])); const unfertig = rows.filter((r) => !r.finished_at); if (unfertig.length > 0) { throw new Error( `Migration ${unfertig.map((r) => r.migration_name).join(", ")} ist unvollständig eingespielt – bitte prüfen.`, ); } const eingespielt: string[] = []; for (const name of migrationen) { const sql = readFileSync(join(ordner, name, "migration.sql")); const pruefsumme = createHash("sha256").update(sql).digest("hex"); const vorhanden = bekannt.get(name); if (vorhanden) { if (vorhanden.checksum !== pruefsumme) { throw new Error(`Migration ${name} wurde nach dem Einspielen verändert (Prüfsumme weicht ab).`); } continue; } log(`Migration ${name} …`); await client.query("BEGIN"); try { const id = randomUUID(); await client.query( `INSERT INTO "_prisma_migrations" (id, checksum, migration_name, started_at) VALUES ($1, $2, $3, now())`, [id, pruefsumme, name], ); await client.query(sql.toString("utf8")); await client.query( `UPDATE "_prisma_migrations" SET finished_at = now(), applied_steps_count = 1 WHERE id = $1`, [id], ); await client.query("COMMIT"); } catch (fehler) { await client.query("ROLLBACK"); throw new Error(`Migration ${name} fehlgeschlagen: ${(fehler as Error).message}`); } eingespielt.push(name); } return { eingespielt, vorhanden: migrationen.length - eingespielt.length }; } finally { await client.query("SELECT pg_advisory_unlock($1)", [SPERRE]).catch(() => {}); await client.end(); } }