Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
/*
* 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();
}