#!/usr/bin/env node /** * Eine Migration erzeugen, ohne die Volltextsuche kaputtzumachen. * * npm run db:migration -- ansprechpartner * npm run db:migration -- ansprechpartner --anwenden * * Das Problem, das dieses Skript loest: "prisma migrate diff" kennt nur * das, was im Prisma-Schema steht. Die Trigramm-Indizes der Suche stehen * aber in prisma/sql/volltextsuche.sql und sind Prisma unbekannt - also * haelt es sie fuer ueberfluessig und schreibt in JEDE erzeugte Migration: * * DROP INDEX "dokument_absender_trgm"; * DROP INDEX "vorgang_titel_trgm"; * * Fuenfmal ist das passiert, fuenfmal mussten die Zeilen von Hand raus. * Auf dem Server waere es nicht aufgefallen, weil update.sh die Indizes * hinterher ohnehin neu anlegt - in der Entwicklung dagegen waere die * Suche still langsam geworden, ohne dass etwas fehlschlaegt. * * Was das Skript macht: * 1. Migration mit "migrate diff" erzeugen. * 2. DROP-Zeilen zu geschuetzten Indizes entfernen und im Kopf * vermerken, welche das waren. * 3. Auf Wunsch (--anwenden) "migrate deploy" laufen lassen und danach * pruefen, dass die geschuetzten Indizes wirklich noch stehen. * * Nicht automatisch angewendet wird die Migration ohne --anwenden: Erst * liest man, was da erzeugt wurde. */ import { execFileSync } from 'node:child_process' import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const WURZEL = join(dirname(fileURLToPath(import.meta.url)), '..') /** * Indizes, die es nur in prisma/sql/ gibt und die Prisma deshalb nicht * kennt. Wer dort einen hinzufuegt, traegt ihn hier ein - sonst faellt er * der naechsten Migration zum Opfer. */ export const GESCHUETZTE_INDIZES = [ { name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' }, { name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' }, ] /** * Entfernt DROP-INDEX-Anweisungen zu geschuetzten Indizes. * * Bewusst eng gefasst: Nur eine Anweisung, die genau einen geschuetzten * Index nennt, faellt weg. Ein DROP INDEX auf einen Prisma-eigenen Index * bleibt stehen - der soll ja weg. Der Kommentar "-- DropIndex", den * Prisma darueber setzt, wird mitgenommen, damit keine verwaisten * Ueberschriften zurueckbleiben. * * @param {string} sql * @param {{name: string}[]} geschuetzt * @returns {{sql: string, entfernt: string[]}} */ export function dropZeilenEntfernen(sql, geschuetzt = GESCHUETZTE_INDIZES) { const namen = geschuetzt.map((eintrag) => eintrag.name) const zeilen = sql.split('\n') const behalten = [] const entfernt = [] for (let i = 0; i < zeilen.length; i++) { const zeile = zeilen[i] const treffer = namen.find( (name) => /^\s*DROP\s+INDEX\b/i.test(zeile) && zeile.includes(name), ) if (!treffer) { behalten.push(zeile) continue } entfernt.push(treffer) // Die "-- DropIndex"-Ueberschrift darueber mitnehmen, samt der // Leerzeile davor - sonst bleiben Luecken im Text stehen. while (behalten.length > 0 && /^\s*--\s*DropIndex\s*$/i.test(behalten[behalten.length - 1])) { behalten.pop() while (behalten.length > 0 && behalten[behalten.length - 1].trim() === '') behalten.pop() } // Leerzeilen, die der entfernten Anweisung folgen, ebenfalls. while (i + 1 < zeilen.length && zeilen[i + 1].trim() === '') i++ } return { sql: behalten.join('\n').replace(/\n{3,}/g, '\n\n').trimStart(), entfernt } } /** * Steht in dem SQL ueberhaupt noch eine Anweisung? * * Wichtig fuer den Normalfall: Wenn Schema und Datenbank uebereinstimmen, * besteht der ganze Diff aus den beiden bekannten DROP-INDEX-Zeilen. Nach * dem Entfernen bleibt nichts uebrig - und eine leere Migration anzulegen * waere schlimmer als gar keine, weil sie als angewendet in der Historie * steht und niemand mehr weiss, wofuer sie gut war. * * @param {string} sql * @returns {boolean} */ export function hatAnweisungen(sql) { return sql .split('\n') .some((zeile) => zeile.trim() !== '' && !zeile.trim().startsWith('--')) } /** Der Kopfkommentar der erzeugten Datei. */ export function kopfBauen(name, entfernt, geschuetzt = GESCHUETZTE_INDIZES) { const zeilen = [`-- ${name}`, '--'] if (entfernt.length === 0) { zeilen.push('-- Erzeugt mit "npm run db:migration". Nichts zu entfernen.') } else { const quellen = [ ...new Set( entfernt.map( (eintrag) => geschuetzt.find((g) => g.name === eintrag)?.quelle ?? 'prisma/sql/', ), ), ] zeilen.push( '-- Erzeugt mit "npm run db:migration". Entfernt wurden die', '-- DROP-INDEX-Anweisungen zu diesen Indizes:', ...entfernt.map((eintrag) => `-- ${eintrag}`), '--', `-- Sie stammen aus ${quellen.join(', ')} und sind Prisma unbekannt;`, '-- ohne sie waere die Volltextsuche still langsam geworden.', ) } zeilen.push('') return zeilen.join('\n') } /** Zeitstempel im Prisma-Format, aus der lokalen Uhr. */ export function ordnernameBauen(name, jetzt = new Date()) { const zwei = (zahl) => String(zahl).padStart(2, '0') const stempel = [ jetzt.getFullYear(), zwei(jetzt.getMonth() + 1), zwei(jetzt.getDate()), zwei(jetzt.getHours()), zwei(jetzt.getMinutes()), zwei(jetzt.getSeconds()), ].join('') return `${stempel}_${name}` } // --- Ab hier: Ausfuehrung ---------------------------------------------------- /** * Prisma direkt ueber sein Eintrittsskript aufrufen, nicht ueber npx. * * Mit "shell: true" wuerde Node warnen (Argumente werden dann nur * aneinandergehaengt statt maskiert), und ohne Shell findet Windows das * npx-Skript nicht. Der Weg ueber node loest beides und ist derselbe, * den scripts/update.sh schon nimmt. */ function prisma(argumente, optionen = {}) { const eintritt = join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js') return execFileSync(process.execPath, [eintritt, ...argumente], { cwd: WURZEL, encoding: 'utf8', ...optionen, }) } function abbruch(meldung) { console.error(`\n Abbruch: ${meldung}\n`) process.exit(1) } function hauptlauf() { const argumente = process.argv.slice(2) const anwenden = argumente.includes('--anwenden') const name = argumente.find((eintrag) => !eintrag.startsWith('--')) if (!name) { abbruch( 'Bitte einen Namen angeben: npm run db:migration -- [--anwenden]\n' + ' Beispiel: npm run db:migration -- ansprechpartner', ) } if (!/^[a-z0-9_]+$/.test(name)) { abbruch(`"${name}" taugt nicht als Ordnername. Erlaubt: a-z, 0-9 und _`) } console.log(`\n Migration "${name}" erzeugen\n`) let roh try { roh = prisma([ 'migrate', 'diff', '--from-schema-datasource', 'prisma/schema.prisma', '--to-schema-datamodel', 'prisma/schema.prisma', '--script', ]) } catch (fehler) { abbruch(`"prisma migrate diff" ist fehlgeschlagen:\n${fehler.message}`) } if (roh.trim() === '' || /^--\s*This is an empty migration/im.test(roh)) { abbruch('Schema und Datenbank stimmen bereits ueberein - es gibt nichts zu migrieren.') } const { sql, entfernt } = dropZeilenEntfernen(roh) if (!hatAnweisungen(sql)) { const grund = entfernt.length > 0 ? `Uebrig blieben nur die bekannten DROP-INDEX-Zeilen (${entfernt.join(', ')}).` : 'Der Vergleich hat keine Anweisungen ergeben.' abbruch( `Es gibt nichts zu migrieren. ${grund}\n` + ' Es wurde keine Migration angelegt.', ) } const ordner = join(WURZEL, 'prisma', 'migrations', ordnernameBauen(name)) mkdirSync(ordner, { recursive: true }) const datei = join(ordner, 'migration.sql') writeFileSync(datei, `${kopfBauen(name, entfernt)}\n${sql.trimEnd()}\n`, 'utf8') console.log(` Geschrieben: ${datei.replace(WURZEL, '.')}`) if (entfernt.length > 0) { console.log('\n Entfernte DROP-INDEX-Anweisungen:') for (const eintrag of entfernt) console.log(` - ${eintrag}`) } else { console.log(' Keine DROP-INDEX-Anweisung zu schuetzenden Indizes gefunden.') } if (!anwenden) { console.log('\n Jetzt: die Datei lesen, dann anwenden mit') console.log(' npm run db:deploy && npm run db:pruefen\n') return } console.log('\n Anwenden ...') try { prisma(['migrate', 'deploy'], { stdio: 'inherit' }) } catch { abbruch('"prisma migrate deploy" ist fehlgeschlagen. Die Datei bleibt liegen.') } indizesPruefen() } /** * Stehen die geschuetzten Indizes noch? * * Der Sinn des ganzen Skripts haengt an dieser Pruefung: Sie ist der * Unterschied zwischen "sollte gutgegangen sein" und "nachgesehen". */ export function indizesPruefen() { const bedingung = GESCHUETZTE_INDIZES.map((eintrag) => `'${eintrag.name}'`).join(', ') const sql = `DO $$ BEGIN IF (SELECT count(*) FROM pg_indexes WHERE indexname IN (${bedingung})) <> ${GESCHUETZTE_INDIZES.length} THEN RAISE EXCEPTION 'Es fehlen geschuetzte Indizes'; END IF; END $$;` // In den Temp-Ordner, nicht ins Repository: Die Datei ist Werkzeug, // kein Bestandteil des Projekts. const datei = join(tmpdir(), `indizes-pruefung-${process.pid}.sql`) writeFileSync(datei, sql, 'utf8') try { prisma(['db', 'execute', '--schema', 'prisma/schema.prisma', '--file', datei], { stdio: 'pipe', }) console.log('\n Geschuetzte Indizes stehen:') for (const eintrag of GESCHUETZTE_INDIZES) console.log(` - ${eintrag.name}`) console.log('') } catch { abbruch( 'Nach der Migration fehlen geschuetzte Indizes.\n' + ' Wiederherstellen mit: npm run db:sql', ) } finally { rmSync(datei, { force: true }) } } if (process.argv[1] && process.argv[1].endsWith('migration-erzeugen.mjs')) { hauptlauf() }