/* * Voert de migraties in db/migrations/ uit tegen DATABASE_URL: alles wat daar ligt en nog niet in * schema_migrations staat, op volgorde van bestandsnaam, elk in een eigen transactie. * * npm run db:migrate # uitvoeren wat openstaat * npm run db:migrate -- --status # alleen kijken; afsluitcode 1 als er iets openstaat * * Dit bestaat sinds de productie-release (24-08-2026): vanaf dat moment is de database niet meer * wegwerpbaar en is `db:setup` op productie verboden terrein. De spelregels, die dit script ook * afdwingt waar het kan: * * * Een migratie heet `0001-korte-naam.sql`. Het nummer bepaalt de volgorde en wordt nooit * hergebruikt; nieuwe migraties komen achteraan. * * Wat één keer op productie gedraaid heeft is onaantastbaar: niet wijzigen, niet hernoemen, * niet weghalen. Zit er een fout in, dan herstelt de vólgende migratie hem. * * Elke migratie draait in één transactie: helemaal of niet. (Dus geen `create index * concurrently` en ander werk dat buiten een transactie moet — dat heeft deze schaal niet nodig.) * * Dezelfde wijziging hoort óók in db/schema.sql: dat bestand blijft de waarheid voor een verse * installatie, de migratie is de weg ernaartoe voor een database die er al staat. db/setup.js * registreert na een verse opbouw daarom alle migraties als al uitgevoerd. * * Bij de eerste keer draaien maakt dit script schema_migrations zelf aan: de productiedatabase is * ouder dan deze tabel, en dat hoort geen handwerk te vragen. */ import { readdir, readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; import pg from "pg"; const here = path.dirname(fileURLToPath(import.meta.url)); const statusOnly = process.argv.includes("--status"); const fail = (msg) => { console.error(msg); process.exit(1); }; /* De map mag ontbreken: dan is er gewoon nog geen migratie geschreven */ let files = []; try { files = (await readdir(path.join(here, "migrations"))).filter((f) => f.endsWith(".sql")).sort(); } catch (err) { if (err.code !== "ENOENT") throw err; } /* Eerst de vorm, dan pas de database: een typefout in een naam hoort hier al te stranden */ const misvormd = files.filter((f) => !/^\d{4}-[a-z0-9-]+\.sql$/.test(f)); if (misvormd.length) fail(`geen geldige migratienaam: ${misvormd.join(", ")} — het patroon is 0001-korte-naam.sql`); const nummers = files.map((f) => f.slice(0, 4)); const dubbel = [...new Set(nummers.filter((n, i) => nummers.indexOf(n) !== i))]; if (dubbel.length) fail(`nummer ${dubbel.join(", ")} is meer dan eens gebruikt — elk nummer hoort bij precies één migratie`); const client = new pg.Client({ connectionString: process.env.DATABASE_URL ?? "postgres://postgres@localhost:5432/liefdesbrieven", }); await client.connect(); try { /* Twee gelijktijdige runs (mens en uitrolscript, bijvoorbeeld) zouden elkaars transacties doorkruisen; de tweede hoort te wachten door niet te beginnen. */ const { rows: [lock] } = await client.query( "select pg_try_advisory_lock(hashtext('liefdesbrieven-migraties')) as vrij" ); if (!lock.vrij) fail("er draait al een migratie tegen deze database — wacht tot die klaar is"); await client.query(`create table if not exists schema_migrations ( filename text primary key, applied_at timestamptz not null default now() )`); const { rows: gedaan } = await client.query( "select filename, applied_at from schema_migrations order by filename" ); const uitgevoerd = new Set(gedaan.map((r) => r.filename)); /* * De database mag niets kennen wat hier niet ligt: dan draait er een werkkopie die achterloopt * op productie (of er is aan een uitgevoerde migratie gemorreld), en in beide gevallen is * doordraaien het enige dat het erger maakt. */ const zoek = gedaan.map((r) => r.filename).filter((f) => !files.includes(f)); if (zoek.length) fail( `de database kent ${zoek.length === 1 ? "een migratie" : `${zoek.length} migraties`} die hier niet ${zoek.length === 1 ? "ligt" : "liggen"}: ${zoek.join(", ")}\n` + `deze werkkopie loopt achter op de database — eerst bijwerken, dan pas migreren` ); const open = files.filter((f) => !uitgevoerd.has(f)); /* Een openstaande migratie die vóór een uitgevoerde sorteert is een volgordeconflict: de plek in de rij is al vergeven. Hernummeren naar het eind, dan klopt de volgorde overal. */ const laatste = gedaan.at(-1)?.filename ?? ""; const tussenin = open.filter((f) => f < laatste); if (tussenin.length) fail(`${tussenin.join(", ")} sorteert vóór het al uitgevoerde ${laatste} — hernummer naar het eind`); if (statusOnly) { const fmt = (d) => new Date(d).toLocaleString("nl-NL", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }); for (const r of gedaan) console.log(` uitgevoerd ${r.filename} (${fmt(r.applied_at)})`); for (const f of open) console.log(` open ${f}`); if (!gedaan.length && !open.length) console.log("geen migraties — db/migrations/ is leeg, db/schema.sql is de hele stand"); else console.log(open.length ? `${open.length} openstaand — draai npm run db:migrate` : "niets openstaand"); process.exitCode = open.length ? 1 : 0; } else if (!open.length) { console.log(`niets te doen (${gedaan.length === 0 ? "geen migraties" : `alle ${gedaan.length} uitgevoerd`})`); } else { for (const [i, f] of open.entries()) { /* Windows-editors zetten nog weleens een BOM voor het bestand; Postgres antwoordt daar met een raadselachtige syntax error op het eerste woord. Eraf dus. */ const sql = (await readFile(path.join(here, "migrations", f), "utf8")).replace(/^\uFEFF/, ""); try { await client.query("begin"); await client.query(sql); await client.query("insert into schema_migrations (filename) values ($1)", [f]); await client.query("commit"); console.log(`${f} uitgevoerd`); } catch (err) { await client.query("rollback").catch(() => {}); const rest = open.length - i - 1; fail( `${f} MISLUKT en teruggedraaid: ${err.message}\n` + `de database staat nog op de stand van vóór dit bestand` + (rest ? `; ${rest} latere migratie${rest === 1 ? " is" : "s zijn"} niet geprobeerd` : "") ); } } } } finally { await client.end(); }