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.