From 73b6dc820e75341181a3b483eaec3d4b6c6fdb9f Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 17:13:34 +0200 Subject: [PATCH] Suchindex ins Schema, Testdatenbank je Lauf frisch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Funde beim Nachsehen, warum `pnpm db:migrate` hängt. **Der Befehl hing nicht, er fragte.** `prisma migrate dev` fand einen Unterschied zwischen schema.prisma und den Migrationen und wartete auf einen Namen für eine neue Migration. Deren Inhalt wäre gewesen, den GIN-Index über den Suchvektor zu löschen: er stand nur in der Migration, weil Unsupported("tsvector") dazu verleitet, den Index für nicht darstellbar zu halten. Er ist darstellbar. Wer die Frage in Eile beantwortet hätte, hätte die gestufte Volltextsuche verloren und es erst gemerkt, wenn die Suche leer bleibt. packages/db/tests/schema-drift.test.ts prüft das jetzt und nennt im Fehlerfall das fehlende Stück. Gegengeprobt: ohne die Zeile im Schema schlägt der Test an. Die Sperre, an der der zweite Versuch scheiterte (P1002), hielt eine verwaiste Verbindung eines abgeschossenen migrate-dev. Wie man den Halter findet und freigibt, steht jetzt in docs/11. **Die Testdatenbank wird verworfen und neu angelegt.** Weil Akten nicht löschbar sind, hatte sie 2487 angesammelt, und drei Zusicherungen auf eine auf zehn Einträge begrenzte Liste fanden ihre eigene Akte nicht mehr darin. Das sieht aus wie ein Fehler im Code und ist keiner. Neu aufsetzen kostet dreieinhalb Sekunden; KEEP_TEST_DB=1 behält den Bestand. Co-Authored-By: Claude Opus 5 --- docs/11-betrieb.md | 24 +++++++++++ docs/13-entscheidungen.md | 36 ++++++++++++++++ packages/db/prisma/schema.prisma | 4 ++ packages/db/scripts/prepare-test-db.mjs | 44 ++++++++++++++++--- packages/db/tests/schema-drift.test.ts | 57 +++++++++++++++++++++++++ web/next-env.d.ts | 2 +- 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/db/tests/schema-drift.test.ts diff --git a/docs/11-betrieb.md b/docs/11-betrieb.md index 8af00cf..0dadd3c 100644 --- a/docs/11-betrieb.md +++ b/docs/11-betrieb.md @@ -261,3 +261,27 @@ Als Anker `<<: *logging` bei jedem Dienst einhängen. Tägliche Aufgabe in der Anwendung, kein eigener Container: abgelaufene Sessions löschen, Quarantäne-Dateien älter als 24 Stunden löschen, PDF-Cache älter als 30 Tage löschen. Der Audit-Log wird nie gelöscht. + +## Wenn eine Migration hängt + +`prisma migrate dev` ist **interaktiv**. Findet es einen Unterschied zwischen `schema.prisma` +und den Migrationen, fragt es nach einem Namen für eine neue Migration und wartet — ohne +Terminal sieht das aus wie ein Aufhänger. `pnpm exec prisma migrate status` sagt +nicht-interaktiv, woran man ist, `migrate diff --from-config-datasource --to-schema +prisma/schema.prisma --script` zeigt den Unterschied. Im Betrieb läuft ohnehin +`migrate deploy`, das fragt nie. + +Wird ein laufendes `migrate dev` abgeschossen, bleibt seine Datenbankverbindung offen und +hält die Sperre, mit der Prisma parallele Migrationen verhindert. Der nächste Versuch läuft +dann in `P1002: Timed out trying to acquire a postgres advisory lock`. Den Halter findet man +so: + +```sql +SELECT l.pid, a.state, now() - a.state_change AS untaetig_seit, a.query + FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid + WHERE l.locktype = 'advisory'; +``` + +Die verwaiste Verbindung erkennt man an `state = idle` und einer Untätigkeit, die zum +Zeitpunkt des Abbruchs passt. `SELECT pg_terminate_backend()` gibt die Sperre frei. +**Nur die untätige beenden** — eine aktive gehört einem laufenden Versuch. diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index a50ad10..6c31abb 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -1045,3 +1045,39 @@ betroffenen Feld. Lichtbilder bleiben ausgenommen: ein Bild wird hochgeladen und geprüft, das ist ein eigener Vorgang mit eigener Prüfkette und keine Feldänderung. + +### Von Hand angelegte Indizes gehören auch ins Schema + +Der GIN-Index über den Suchvektor stand in der Migration, aber nicht in `schema.prisma` — +`Unsupported("tsvector")` verleitet dazu, den Index für nicht darstellbar zu halten. Er ist +darstellbar: `@@index([vector], type: Gin)`. + +Ohne die Zeile hielt Prisma den Index für einen Rückstand und fragte bei jedem `migrate dev` +nach einem Namen für eine neue Migration. Deren Inhalt wäre gewesen, **den Suchindex zu +löschen**. Wer die Frage in Eile beantwortet, verliert die gestufte Volltextsuche und merkt es +erst, wenn die Suche leer bleibt — der Index ist keine Optimierung, ohne ihn scannt jede +Anfrage sechs Zeilen je Akte. + +`packages/db/tests/schema-drift.test.ts` prüft das jetzt: `migrate diff` gegen die Datenbank +des Testlaufs muss leer sein. Die Fehlermeldung nennt das fehlende Stück, damit man nicht +sucht. + +Die Regel daraus: **was eine Migration von Hand anlegt, muss im Schema stehen, oder Prisma +räumt es beim nächsten `migrate dev` weg.** Das gilt für Indizes; Regeln, Trigger und CHECKs +kennt Prisma nicht und lässt sie in Ruhe. + +### Die Testdatenbank wird bei jedem Lauf neu aufgesetzt + +Sie wurde bisher nur angelegt, wenn sie fehlte. Weil Akten, Verlaufseinträge und +Protokollzeilen nach harter Regel 2 nicht löschbar sind, bleibt aber alles stehen, was ein +Testlauf anlegt — nach einigen Dutzend Läufen standen 2487 Akten darin. + +Das ging so lange gut, bis drei Zusicherungen brachen, die eine **auf zehn Einträge begrenzte +Liste** prüfen: die eigene, gerade angelegte Akte war nicht mehr unter den zehn nächsten +Fristen. Das ist die unangenehmste Sorte Fehlschlag, weil sie aussieht wie ein Fehler im Code +und keiner ist. + +`test:prepare` verwirft die Datenbank jetzt und legt sie neu an; das dauert dreieinhalb +Sekunden. Der Aufwand, so einen Fehlschlag einmal falsch zu deuten, ist grösser. +`KEEP_TEST_DB=1` behält den Bestand, für das schnelle Nacheinander an einer einzelnen +Testdatei. Eine Sicherung im Skript besteht darauf, dass der Name auf `_test` endet. diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index eb31e44..b8b5e02 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -847,6 +847,10 @@ model SearchIndex { @@id([targetType, targetId, minClearance]) @@index([targetType, minClearance]) + /// Der GIN-Index ueber den Vektor. Er muss **hier** stehen und nicht nur in + /// der Migration: sonst sieht `prisma migrate dev` einen Unterschied zur + /// Datenbank und will ihn wegraeumen. Siehe docs/13-entscheidungen.md. + @@index([vector], type: Gin) } /// Eine gespeicherte Suche. Die mitgelieferten Ansichten stehen im Programm, diff --git a/packages/db/scripts/prepare-test-db.mjs b/packages/db/scripts/prepare-test-db.mjs index 9538c42..936fa55 100644 --- a/packages/db/scripts/prepare-test-db.mjs +++ b/packages/db/scripts/prepare-test-db.mjs @@ -6,8 +6,19 @@ import { loadEnvFromRepoRoot } from '../dist/loadEnv.js' import { resolveTestDatabaseUrl } from '../dist/testDatabase.js' /** - * Legt die Testdatenbank an, falls sie fehlt, und bringt sie auf den aktuellen - * Migrationsstand. Wird von `pnpm test` vorgeschaltet. + * Setzt die Testdatenbank neu auf und bringt sie auf den Migrationsstand. + * Wird von `pnpm test` vorgeschaltet. + * + * **Neu aufsetzen, nicht nur anlegen.** Verlaufseinträge, Akten und + * Protokollzeilen sind nach harter Regel 2 aus CLAUDE.md nicht löschbar — was + * ein Testlauf anlegt, bleibt für immer stehen. Nach einigen Dutzend Läufen + * standen 2487 Akten in der Datenbank, und Tests, die eine auf zehn Einträge + * begrenzte Liste prüfen, fanden ihre eigene Akte nicht mehr darin. Solche + * Fehlschläge sehen aus wie ein Fehler im Code und sind keiner; das kostet mehr + * Zeit als das Neuaufsetzen. + * + * `KEEP_TEST_DB=1` behält den Bestand. Für das schnelle Nacheinander beim + * Arbeiten an einer einzelnen Testdatei. * * Läuft nur in der Entwicklung und in der CI, nie im Betrieb. */ @@ -21,12 +32,35 @@ maintenance.pathname = '/postgres' const client = new pg.Client({ connectionString: maintenance.toString() }) await client.connect() +// Bezeichner lassen sich nicht als Parameter binden, deshalb quotiert. Der Name +// kommt aus resolveTestDatabaseUrl und endet immer auf _test. +const quoted = `"${dbName.replace(/"/g, '""')}"` + +if (!dbName.endsWith('_test')) { + throw new Error(`Sicherung: ${dbName} endet nicht auf _test, wird nicht angefasst`) +} + try { const { rowCount } = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [dbName]) - if (rowCount === 0) { - // Bezeichner lassen sich nicht als Parameter binden, deshalb quotiert. - await client.query(`CREATE DATABASE "${dbName.replace(/"/g, '""')}"`) + const behalten = process.env.KEEP_TEST_DB === '1' + + if (rowCount > 0 && !behalten) { + // Offene Verbindungen verhindern das Verwerfen — etwa ein Prisma Studio + // oder ein abgebrochener Testlauf. + await client.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [dbName], + ) + await client.query(`DROP DATABASE ${quoted}`) + console.log(`[test-db] ${dbName} verworfen`) + } + + if (rowCount === 0 || !behalten) { + await client.query(`CREATE DATABASE ${quoted}`) console.log(`[test-db] ${dbName} angelegt`) + } else { + console.log(`[test-db] ${dbName} behalten (KEEP_TEST_DB=1)`) } } finally { await client.end() diff --git a/packages/db/tests/schema-drift.test.ts b/packages/db/tests/schema-drift.test.ts new file mode 100644 index 0000000..87d4fad --- /dev/null +++ b/packages/db/tests/schema-drift.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +/** + * Schema und Migrationen dürfen nicht auseinanderlaufen. + * + * Der Anlass war handfest: ein von Hand angelegter GIN-Index über den + * Suchvektor stand in der Migration, aber nicht in `schema.prisma`. Prisma + * hielt das für einen Rückstand und fragte bei jedem `migrate dev` nach einem + * Namen für eine neue Migration — deren Inhalt gewesen wäre, **den Suchindex + * zu löschen**. Wer die Frage in Eile beantwortet, verliert die gestufte + * Volltextsuche und merkt es erst, wenn die Suche leer bleibt. + * + * Der Vergleich läuft gegen die Datenbank, auf die der Testlauf zeigt. Sie + * trägt alle Migrationen, also ist ein Unterschied zum Schema genau der + * Rückstand, den `migrate dev` sonst anbietet. + * + * `--exit-code`: 0 heisst kein Unterschied, 2 heisst Unterschied. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +suite('Schema und Migrationen', () => { + it('haben keinen Unterschied, den migrate dev anbieten würde', () => { + const wurzel = path.resolve(__dirname, '..') + + let ausgabe = '' + let code = 0 + try { + ausgabe = execFileSync( + 'node', + [ + require.resolve('prisma/build/index.js', { paths: [wurzel] }), + 'migrate', + 'diff', + '--from-config-datasource', + '--to-schema', + path.join('prisma', 'schema.prisma'), + '--script', + '--exit-code', + ], + { cwd: wurzel, encoding: 'utf8', env: { ...process.env }, timeout: 120_000 }, + ) + } catch (error) { + const fehler = error as { status?: number; stdout?: string; stderr?: string } + code = fehler.status ?? 1 + ausgabe = `${fehler.stdout ?? ''}${fehler.stderr ?? ''}` + } + + // Die Meldung nennt das fehlende Stück, damit man nicht suchen muss. + expect(ausgabe.replace(/^Loaded Prisma config.*$/m, '').trim(), ausgabe).not.toMatch( + /^--\s*(Create|Drop|Alter|Add)/m, + ) + expect(code, ausgabe).toBe(0) + }, 130_000) +}) diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 341b947..830fb59 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.