diff --git a/package.json b/package.json index 66d4f64..0f1b1a5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:deploy": "prisma migrate deploy", + "db:migration": "node scripts/migration-erzeugen.mjs", + "db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"", "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", "db:seed": "prisma db seed", "db:studio": "prisma studio", diff --git a/scripts/migration-erzeugen.d.mts b/scripts/migration-erzeugen.d.mts new file mode 100644 index 0000000..58a407f --- /dev/null +++ b/scripts/migration-erzeugen.d.mts @@ -0,0 +1,32 @@ +/** + * Typen fuer das Werkzeugskript. + * + * Das Skript selbst bleibt .mjs - es laeuft ohne Bauschritt, auch auf + * einem Rechner, auf dem nur Node steht. Die Typen daneben sorgen dafuer, + * dass die Tests trotzdem geprueft werden und nicht stillschweigend + * gegen "any" laufen. + */ + +export type GeschuetzterIndex = { + name: string + quelle: string +} + +export declare const GESCHUETZTE_INDIZES: GeschuetzterIndex[] + +export declare function dropZeilenEntfernen( + sql: string, + geschuetzt?: GeschuetzterIndex[], +): { sql: string; entfernt: string[] } + +export declare function hatAnweisungen(sql: string): boolean + +export declare function kopfBauen( + name: string, + entfernt: string[], + geschuetzt?: GeschuetzterIndex[], +): string + +export declare function ordnernameBauen(name: string, jetzt?: Date): string + +export declare function indizesPruefen(): void diff --git a/scripts/migration-erzeugen.mjs b/scripts/migration-erzeugen.mjs new file mode 100644 index 0000000..1bfb7f6 --- /dev/null +++ b/scripts/migration-erzeugen.mjs @@ -0,0 +1,296 @@ +#!/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() +} diff --git a/tests/migration-erzeugen.test.ts b/tests/migration-erzeugen.test.ts new file mode 100644 index 0000000..6389811 --- /dev/null +++ b/tests/migration-erzeugen.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import { + dropZeilenEntfernen, + hatAnweisungen, + kopfBauen, + ordnernameBauen, +} from '../scripts/migration-erzeugen.mjs' + +/** + * Der Migrations-Wrapper. + * + * Die eine Sache, die hier schiefgehen darf und nicht schiefgehen soll: + * zu viel entfernen. Ein DROP INDEX, das Prisma zu Recht schreibt, muss + * stehen bleiben - sonst repariert das Skript die Suche und zerlegt + * dafuer das Schema. + */ + +const GESCHUETZT = [ + { name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' }, + { name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' }, +] + +describe('DROP-Zeilen entfernen', () => { + it('nimmt die geschützte Anweisung samt Überschrift heraus', () => { + const roh = [ + '-- DropIndex', + 'DROP INDEX "dokument_absender_trgm";', + '', + '-- AlterTable', + 'ALTER TABLE "kontakt" ADD COLUMN "anrede" TEXT;', + ].join('\n') + + const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT) + + expect(entfernt).toEqual(['dokument_absender_trgm']) + expect(sql).not.toContain('DROP INDEX') + expect(sql).not.toContain('DropIndex') + expect(sql).toContain('ALTER TABLE "kontakt"') + }) + + it('lässt ein DROP INDEX auf einen Prisma-eigenen Index stehen', () => { + // Der Kern der Sache: Das Skript darf nicht alles wegräumen, was nach + // DROP INDEX aussieht - manche sollen wirklich weg. + const roh = ['-- DropIndex', 'DROP INDEX "vertrag_betreuteId_status_idx";'].join('\n') + + const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT) + + expect(entfernt).toEqual([]) + expect(sql).toContain('DROP INDEX "vertrag_betreuteId_status_idx";') + expect(sql).toContain('-- DropIndex') + }) + + it('entfernt mehrere geschützte Anweisungen und meldet beide', () => { + const roh = [ + '-- DropIndex', + 'DROP INDEX "dokument_absender_trgm";', + '', + '-- DropIndex', + 'DROP INDEX "vorgang_titel_trgm";', + '', + '-- CreateTable', + 'CREATE TABLE "schreibvorlage" ();', + ].join('\n') + + const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT) + + expect(entfernt).toEqual(['dokument_absender_trgm', 'vorgang_titel_trgm']) + expect(sql.startsWith('-- CreateTable')).toBe(true) + }) + + it('verträgt IF EXISTS und abweichende Schreibweise', () => { + const roh = 'drop index if exists "vorgang_titel_trgm";' + expect(dropZeilenEntfernen(roh, GESCHUETZT).entfernt).toEqual(['vorgang_titel_trgm']) + }) + + it('lässt eine Migration ohne Treffer unverändert', () => { + const roh = ['-- AlterTable', 'ALTER TABLE "vorgang" ADD COLUMN "x" TEXT;'].join('\n') + const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT) + expect(entfernt).toEqual([]) + expect(sql).toBe(roh) + }) + + it('greift nicht bei einem bloßen Vorkommen des Namens im Text', () => { + // Ein Kommentar, der den Index nennt, ist keine Anweisung. + const roh = '-- dokument_absender_trgm bleibt bestehen' + const { sql, entfernt } = dropZeilenEntfernen(roh, GESCHUETZT) + expect(entfernt).toEqual([]) + expect(sql).toBe(roh) + }) +}) + +describe('Leere Migration erkennen', () => { + it('sieht eine echte Anweisung', () => { + expect(hatAnweisungen('-- AlterTable\nALTER TABLE "x" ADD COLUMN "y" TEXT;')).toBe(true) + }) + + it('hält reinen Kommentartext für leer', () => { + // Der Normalfall: Schema und Datenbank stimmen überein, der ganze + // Diff bestand aus den zwei bekannten DROP-Zeilen. Dann darf keine + // Migration entstehen — eine leere in der Historie ist schlimmer als + // keine. + expect(hatAnweisungen('-- nur ein Kommentar\n\n-- noch einer')).toBe(false) + }) + + it('hält Leerraum für leer', () => { + expect(hatAnweisungen(' \n\n ')).toBe(false) + }) +}) + +describe('Kopfkommentar', () => { + it('benennt die entfernten Indizes und ihre Quelle', () => { + const kopf = kopfBauen('ansprechpartner', ['vorgang_titel_trgm'], GESCHUETZT) + expect(kopf).toContain('vorgang_titel_trgm') + expect(kopf).toContain('prisma/sql/volltextsuche.sql') + }) + + it('sagt auch, wenn nichts zu entfernen war', () => { + expect(kopfBauen('leer', [], GESCHUETZT)).toContain('Nichts zu entfernen') + }) +}) + +describe('Ordnername', () => { + it('baut den Zeitstempel im Prisma-Format', () => { + expect(ordnernameBauen('anrede', new Date(2026, 7, 23, 9, 5, 3))).toBe( + '20260823090503_anrede', + ) + }) +})