Migrationen erzeugen, ohne die Suche kaputtzumachen

"prisma migrate diff" kennt die Trigramm-Indizes aus
prisma/sql/volltextsuche.sql nicht und schreibt deshalb in jede
Migration ein DROP INDEX dafuer. Fuenfmal mussten die Zeilen von Hand
raus. Auf dem Server waere es folgenlos geblieben - update.sh legt die
Indizes hinterher neu an -, in der Entwicklung dagegen waere die Suche
still langsam geworden.

  npm run db:migration -- <name> [--anwenden]
  npm run db:pruefen

Aufbau:
- Die Liste der geschuetzten Indizes steht an einer Stelle. Wer in
  prisma/sql/ einen hinzufuegt, traegt ihn dort ein.
- dropZeilenEntfernen() ist bewusst eng: Nur eine DROP-Anweisung, die
  einen geschuetzten Index nennt, faellt weg. Ein DROP INDEX auf einen
  Prisma-eigenen Index bleibt stehen - der soll ja weg. Ein Kommentar,
  der den Namen bloss erwaehnt, ebenfalls.
- Der Kopf der erzeugten Datei sagt, was entfernt wurde und warum.
- Nach --anwenden wird gegen die Datenbank geprueft, ob die Indizes
  wirklich noch stehen. Das ist der Unterschied zwischen "sollte
  gutgegangen sein" und "nachgesehen".

Beim Probelauf gefunden und behoben: Stimmen Schema und Datenbank
ueberein, besteht der ganze Diff aus den beiden bekannten DROP-Zeilen.
Das Skript legte dafuer eine leere Migration an - und eine leere
Migration in der Historie ist schlimmer als keine, weil sie als
angewendet gilt und niemand mehr weiss, wofuer sie gut war. Jetzt bricht
es vorher ab, ohne einen Ordner zu hinterlassen.

Die Typen liegen als .d.mts daneben: Das Skript bleibt .mjs und laeuft
ohne Bauschritt, die Tests laufen trotzdem nicht gegen "any".

Geprueft: zwoelf Einheitstests, dazu von Hand der Lauf mit echter
Schemaaenderung und der Lauf ohne.

Refs #24
This commit is contained in:
Florian Wilde
2026-08-23 19:25:09 +02:00
parent 98d1338eae
commit 3153e6179d
4 changed files with 458 additions and 0 deletions
+2
View File
@@ -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",
+32
View File
@@ -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
+296
View File
@@ -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 -- <name> [--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()
}
+128
View File
@@ -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',
)
})
})