Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

6.3 kB Ruw bestand
/*
 * 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();
}