Bugfixes, Security und Umbau auf einheitliche Namen

This commit is contained in:
Thomas Adamek 2026-10-19 10:13:56 +02:00
parent 1ce9be1e2e
commit 6fa86b5941
167 changed files with 9606 additions and 9361 deletions

View file

@ -12,7 +12,7 @@
SESSION_SECRET=
# Zugang des Plattform-Admins: legt Projekte an und löscht sie, kommt an jedes
# Projekt. Mindestens 16 Zeichen.
# Projekt. Mindestens 32 Zeichen.
# openssl rand -hex 24
ADMIN_TOKEN=

View file

@ -4,7 +4,7 @@ Wie man KI-Werkzeuge so einrichtet und anspricht, dass sie den Munin
tatsächlich benutzen.
> Dieselben Inhalte stehen für Nutzer auch in der Weboberfläche unter
> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/hilfe/seiten/`). Wer hier
> `/hilfe/mit-agenten-arbeiten` (Quelle: `internal/help/pages/`). Wer hier
> etwas ändert, sollte dort nachziehen.
---

View file

@ -156,7 +156,7 @@ sucht.
### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim
Suchen (siehe `faltUmlaut` in `internal/search/index.go`).
Suchen (siehe `foldUmlauts` in `internal/search/index.go`).
```
prüfung ↔ prufung ✓
@ -241,7 +241,7 @@ Problem.
### 2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14)
Umgesetzt in `internal/search/varianten.go`. Zu jedem Suchwort sucht der Index
Umgesetzt in `internal/search/variants.go`. Zu jedem Suchwort sucht der Index
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
| Variante | Beispiel | Gewicht | ab Länge |
@ -345,11 +345,11 @@ Erstbefüllung aus vorhandener Dokumentation.
### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
**Umsetzung:** `internal/dubletten` (bis 2026-10-16 `internal/mcpserver/dubletten.go`;
**Umsetzung:** `internal/duplicates` (bis 2026-10-16 `internal/mcpserver/dubletten.go`;
seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
sondern der Anteil gemeinsamer Titelwörter (`search.Ueberdeckung`, in beide
sondern der Anteil gemeinsamer Titelwörter (`search.Coverage`, in beide
Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
„Deployment-Prozess", wenn dort „release" steht.
@ -400,12 +400,12 @@ Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag
gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein
gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein
Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn
**beide** Titel dasselbe haben (`ohneGemeinsamesPraefix` in
`internal/dubletten`). Verschiedene Präfixe bleiben im
**beide** Titel dasselbe haben (`withoutCommonPrefix` in
`internal/duplicates`). Verschiedene Präfixe bleiben im
Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne
Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter
gleichem Präfix fällt weiter auf. Tests: `TestOhneGemeinsamesPraefix`,
`TestRememberGemeinsamesPraefixKeineDublette`.
gleichem Präfix fällt weiter auf. Tests: `TestWithoutCommonPrefix`,
`TestRememberCommonPrefixNoDuplicate`.
Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen
Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen
@ -421,7 +421,7 @@ Fehlalarme mit je 50 %:
Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von
„postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt
jetzt nur wörtlich, Titelwörter weiter mit allen Varianten
(`search.UeberdeckungMitSuchbegriffen`). Der Kernfall „Release-Ablauf“
(`search.CoverageWithSearchTerms`). Der Kernfall „Release-Ablauf“
gegen den Suchbegriff `release` bleibt erkannt.
2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit
PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet —
@ -432,8 +432,8 @@ Fehlalarme mit je 50 %:
allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den
Kandidaten erst zu laden.
Tests: `TestUeberdeckungMitSuchbegriffen`, `TestRememberSuchbegriffNurWoertlich`,
`matched_words` in `TestRememberMeldetDublette`.
Tests: `TestCoverageWithSearchTerms`, `TestRememberSearchTermOnlyLiteral`,
`matched_words` in `TestRememberReportsDuplicate`.
### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
@ -442,7 +442,7 @@ Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine
Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
aktuell ist).
**Umsetzung** (`internal/mcpserver/kuratiert.go`), Muster wie bei 3.3:
**Umsetzung** (`internal/mcpserver/curated.go`), Muster wie bei 3.3:
- `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet
`judgment_required` mit Handlungsanweisung.
@ -524,7 +524,7 @@ ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
**Umgesetzt:** Tabelle `entry_versions`
(`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle
(`migrations/004_change_history.sql`). `entries` bleibt die aktuelle
Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
@ -627,17 +627,17 @@ WP12).
**✅ Umgesetzt 2026-09-24:**
- **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal),
- **Schema:** `migrations/005_trash.sql`: `deleted_at` (NULL = normal),
`deleted_by` und ein Teilindex nur über den Papierkorb.
- **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr,
- **Store:** `internal/store/trash.go`. `DeleteEntry` gibt es nicht mehr,
an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass
jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur
`PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im
Papierkorb liegt.
- **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die
- **Lesewege:** Alle tragen `notInTrash`. `ListTags` schließt die
Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex
aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen.
`TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash`
`TestTrashInvisible` prüft jeden Weg einzeln, `TestForgetMovesToTrash`
dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`).
- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag
zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf
@ -656,7 +656,7 @@ WP12).
löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu
gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am
gedrückten Knopf (`event.submitter`).
- **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in
- **Frist:** `store.TrashRetention` = 30 Tage. `purgeTrashAll` in
`main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und
schreibt je Eintrag eine Protokollzeile mit Herkunft `system`.
Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie
@ -693,7 +693,7 @@ kein Betriebsdatum.
2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es
auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand
angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des
Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`),
Requests. Gelöst über einen veränderbaren Halter im Context (`actorHolder`),
den die Anmelde-Middleware befüllt.
### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell)
@ -800,7 +800,7 @@ genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde
(nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace
auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die
Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`,
`.arbeitsbereich*` in `static/style.css`).
`.workspace*` in `static/style.css`).
9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei
einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner,
optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es
@ -826,7 +826,7 @@ und am selben Tag behoben):
`pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace.
Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1)
griff dort nicht. Jetzt gehen beide in jedem Durchgang über
`fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus
`forEachWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus
der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces
dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber
gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`.
@ -840,8 +840,8 @@ und am selben Tag behoben):
`EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen.
Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner
erzeugt. `./munin -export` exportiert alle Workspaces und endet mit
Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`,
`TestExportKnopfImZweitenWorkspace`, `main_test.go`.
Fehlercode, wenn einer scheitert. Tests: `TestExportSeparatesWorkspaces`,
`TestExportButtonInSecondWorkspace`, `main_test.go`.
Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr
an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
@ -900,10 +900,10 @@ unterscheiden.
**✅ Umgesetzt 2026-09-24:**
- **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle.
- **Seite:** `/p/{project}/usage` (`handlers_usage.go`), für jede Rolle.
Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
sieht.
- **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert
- **Zahlen:** `store.UsageSummaries` (`internal/store/usage.go`) liefert
Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den
Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt
keine eigene Zähltabelle, die auseinanderlaufen könnte.
@ -953,7 +953,7 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
**✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.**
- **Migration 007** (`007_key_ablauf.sql`, in beiden Ordnern gleich):
- **Migration 007** (`007_key_expiry.sql`, in beiden Ordnern gleich):
`api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei
`revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst
keine Daten an.
@ -970,8 +970,8 @@ oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
- **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr.
- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf
derselbe Weg in der Kontroll-Datenbank.
- **Tests:** `TestKeyAblauf` (Store, auch gegen PostgreSQL),
`TestKeyMitAblaufdatum` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
- **Tests:** `TestKeyExpiry` (Store, auch gegen PostgreSQL),
`TestKeyWithExpiryDate` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
@ -1237,14 +1237,14 @@ Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen:
| Teil | Datei | Aufgabe |
|---|---|---|
| `Speicher` | `speicher.go`, `speicher_sqlite.go`, `speicher_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. |
| `dialekt` | `dialekt.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. |
| `pool`, `transaktion` | `verbindung.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. |
| `Backend` | `backend.go`, `backend_sqlite.go`, `backend_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. |
| `dialect` | `dialect.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. |
| `pool`, `transaction` | `pool.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. |
```go
type dialekt interface {
rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert)
migrationsUnterordner() string // "sqlite" bzw. "postgres"
migrationsSubdir() string // "sqlite" bzw. "postgres"
}
```
@ -1254,10 +1254,10 @@ Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam.
deshalb, wo sie ist.
**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides`
(`nutzung.go`) und `EmptySearches` (`suchprotokoll.go`) nutzten `MAX()` mit
(`usage.go`) und `EmptySearches` (`searchlog.go`) nutzten `MAX()` mit
Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide
nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab
3.25). Neuer Test: `TestCuratedOverridesJuengsterVorgang`.
3.25). Neuer Test: `TestCuratedOverridesLatestEvent`.
#### Entscheidung: bewusst schlichtes SQL
@ -1281,8 +1281,8 @@ Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text,
#### Entscheidung: Migrationen je Datenbank getrennt
```
internal/store/migrations/sqlite/001_init.sql … 006_suchprotokoll.sql
internal/store/migrations/postgres/001_init.sql … 006_suchprotokoll.sql
internal/store/migrations/sqlite/001_init.sql … 006_search_log.sql
internal/store/migrations/postgres/001_init.sql … 006_search_log.sql
internal/store/migrations_control/sqlite/…
internal/store/migrations_control/postgres/…
```
@ -1415,7 +1415,7 @@ Leseverbindung.
`ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich
wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen.
- **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite
(bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `nutzung.go`
(bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `usage.go`
vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten.
- **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn,
SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar).
@ -1510,7 +1510,7 @@ diesem Ablauf:
| Wann | Was |
|---|---|
| Bei jeder Änderung | `go test ./...` (nur SQLite) |
| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRundreisePostgres`) gegen PostgreSQL |
| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRoundTripPostgres`) gegen PostgreSQL |
| Nach Änderungen am Schreibweg | Lasttest (Auslöser wie in `LASTTEST.md`). Läuft vorerst nur gegen SQLite; die PostgreSQL-Fassung kommt mit Schritt 4 |
| Vor einer Auslieferung | einmal alles gegen beide Datenbanken |
@ -1535,7 +1535,7 @@ der eigentliche Gewinn, aber auch die heikelste Stelle.
- Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden
hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach
gelöschtem Workspace).
- Export und Import laufen unverändert, `TestRundreisePostgres` belegt es.
- Export und Import laufen unverändert, `TestRoundTripPostgres` belegt es.
- Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll
(PostgreSQL 18 im Container, MacBook):
@ -1543,9 +1543,9 @@ der eigentliche Gewinn, aber auch die heikelste Stelle.
|---|---|---|
| `get` (Eintrag nach Bezeichner) | Index `entries_project_id_slug_key` | 0,03 ms |
| Liste, neueste 50 | Index `idx_entries_project_updated` rückwärts | 0,03 ms |
| Rückverweise, verwandte Einträge | `idx_entry_links_projekt`, Primärschlüssel | 0,01 ms |
| Rückverweise, verwandte Einträge | `idx_entry_links_project`, Primärschlüssel | 0,01 ms |
| Protokoll je Projekt | Index `idx_audit_ts` | 0,04 ms |
| Liste nach Schlagwort | `idx_entry_tags_projekt`, dann Hash Join | 1,0 ms |
| Liste nach Schlagwort | `idx_entry_tags_project`, dann Hash Join | 1,0 ms |
| Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms |
| Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms |
| Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,4 ms |
@ -1587,7 +1587,7 @@ Zwei Lücken, die mit PostgreSQL sichtbar wurden: Die Datenbank ist dort ein
eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus
vielen Handgriffen.
**`/live` und `/ready`** (`internal/web/handlers_bereitschaft.go`):
**`/live` und `/ready`** (`internal/web/handlers_ready.go`):
| Pfad | Prüft | Daran hängt |
|---|---|---|
@ -1600,7 +1600,7 @@ vielen Handgriffen.
Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet
nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log.
- Alle drei Pfade erscheinen nicht im Zugriffsprotokoll.
- Test: `TestLiveUndReady` (Kontroll-Datenbank geschlossen → `/ready` 503,
- Test: `TestLiveAndReady` (Kontroll-Datenbank geschlossen → `/ready` 503,
`/live` weiter 200, keine Einzelheiten in der Antwort).
**`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit
@ -1655,21 +1655,21 @@ Agenten bekamen bei `remember` eine Rückfrage mit Kandidaten (3.3), Menschen
im Formular nicht. Das war die letzte Lücke im Schutz gegen doppeltes Wissen.
- Die Prüfung zog dafür aus `internal/mcpserver` in ein eigenes Paket
`internal/dubletten` (`Pruefe`). MCP und Weboberfläche urteilen so mit
`internal/duplicates` (`Check`). MCP und Weboberfläche urteilen so mit
derselben Logik; zwei Fassungen liefen mit der Zeit auseinander.
- `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die
Kandidaten als JSON, samt gemeinsamer Wörter. `static/aehnlich.js` fragt
Kandidaten als JSON, samt gemeinsamer Wörter. `static/similar.js` fragt
nach einer Tipp-Pause (400 ms) und zeigt unter dem Titel „Gibt es schon?“.
- **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und
entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit
der angefangene Text bleibt.
- Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben,
ist es eine Aktualisierung — dann gibt es keinen Hinweis.
- Tests: `TestAehnlicheEintraege`, `TestFormularUndKuerzel`.
- Tests: `TestSimilarEntries`, `TestFormAndShortcuts`.
### 5.8 Rückfrage bei ungespeicherten Änderungen — ✅ umgesetzt (2026-10-16)
`static/ungespeichert.js` an Formularen mit `data-ungespeichert` (das
`static/unsaved.js` an Formularen mit `data-unsaved` (das
Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der
Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer
tippt und wieder löscht, wird nicht gefragt. Beim Speichern keine Rückfrage.
@ -1683,7 +1683,7 @@ Unter einem Eintrag steht jetzt neben *Verweist hierher* der Kasten *Auch
verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion
wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt
dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist
hierher*. Test: `TestAuchVerwandt`.
hierher*. Test: `TestAlsoRelated`.
### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16)
@ -1695,8 +1695,8 @@ Einzelansicht. Jetzt:
`?veraltet=1` zeigt genau diese.
- Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste.
- Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`.
Tests: `TestVeralteteEintraegeFilternUndZaehlen` (auch PostgreSQL),
`TestVeralteteEintraege`.
Tests: `TestStaleEntriesFilterAndCount` (auch PostgreSQL),
`TestStaleEntries`.
### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16)
@ -1706,23 +1706,23 @@ Zugang (Liste aus `store.AuditActors`), Zeitraum (ganze Tage), im Protokoll
gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse
ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200
Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL),
`TestProtokollFiltern`.
`TestAuditFilterForm`.
### 5.12 Systemseite — ✅ umgesetzt (2026-10-16)
`/admin/system`, nur Plattform-Admin:
- Datenbank (`Speicher.Beschreibung`, ohne Passwort) und Bereitschaft wie
- Datenbank (`Backend.Description`, ohne Passwort) und Bereitschaft wie
`/ready`.
- Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und
die daraus folgende Höchstzahl an Workspaces (`Speicher.Kapazitaet`,
`Kapazitaet.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
die daraus folgende Höchstzahl an Workspaces (`Backend.Capacity`,
`Capacity.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über
`Store.Verbindungen`).
`Store.PoolStats`).
- Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`,
`vcs.modified`), Go-Fassung, Start und Laufzeit.
Test: `TestSystemseite`.
Test: `TestSystemPage`.
### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16)
@ -1734,32 +1734,32 @@ nicht ehrlich: Jedes Projekt hat seinen eigenen BM25-Index, die Werte sind
nicht vergleichbar.
Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und
ein Agent sehen weiter genau ein Projekt. Test: `TestSucheInAllenProjekten`
ein Agent sehen weiter genau ein Projekt. Test: `TestSearchAllProjects`
(auch: ein Projektzugang kommt nicht dran).
### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16)
`static/tastenkuerzel.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten,
`static/shortcuts.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten,
`Strg+Enter`/`Cmd+Enter` speichern. Das Ziel einer Taste steht im Template
(`data-kuerzel="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte
(`data-shortcut="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte
bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von
Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser.
### 5.15 Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare — ✅ umgesetzt (2026-10-16)
- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/hilfe/seiten/workspace-admin.md`):
- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/help/pages/workspace-admin.md`):
Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick
behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege,
Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt
eine **Zielgruppe** (`FuerAlle`, `FuerAdmins`, `FuerPlattformAdmin`) das
eine **Zielgruppe** (`ForEveryone`, `ForAdmins`, `ForPlatformAdmin`) das
bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf
Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link
innerhalb der Hilfe trifft eine vorhandene Überschrift**
(`TestAnkerInLinksGibtEs`).
(`TestLinkAnchorsExist`).
- **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt
unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus
dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“.
- **Formulare:** Die Breitenklassen (`kurz`, `mittel`, `lang`) begrenzen jetzt
- **Formulare:** Die Breitenklassen (`short`, `medium`, `long`) begrenzen jetzt
nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise
brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen
Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-,
@ -1782,7 +1782,7 @@ kommen drei Abschnitte:
1. **Zustand** — ein Kasten mit allem, was zu tun ist, leer heißt „Alles in
Ordnung“. *Warnungen:* Kapazität ab 80 % der höchstens möglichen
Workspaces; Hinweise des Speichers (`Speicher.Hinweise`, heute: PostgreSQL
Workspaces; Hinweise des Speichers (`Backend.Hints`, heute: PostgreSQL
sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten;
WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf
einem anderen Schema-Stand als das Programm; der letzte Export
@ -1804,20 +1804,20 @@ kommen drei Abschnitte:
**Umsetzung:**
- `internal/store/systemstand.go`: `Store.Bestand`, `Store.Belegung` (über
eine neue Dialekt-Methode `belegung`), `Store.Aktivitaet`,
`Store.Schemastand`. Keys werden in Go gezählt, mit denselben Regeln wie
- `internal/store/sysinfo.go`: `Store.Inventory`, `Store.DiskUsage` (über
eine neue Dialekt-Methode `diskUsage`), `Store.Activity`,
`Store.SchemaVersion`. Keys werden in Go gezählt, mit denselben Regeln wie
die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`).
- `internal/statistik`: Der `Sammler` fragt alle Workspaces ab und merkt sich
das Ergebnis 5 Minuten (`MindestAbstand`). Gerechnet wird beim Aufruf der
- `internal/stats`: Der `Collector` fragt alle Workspaces ab und merkt sich
das Ergebnis 5 Minuten (`MinInterval`). Gerechnet wird beim Aufruf der
Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das
Aufräumen und der Export beim Start ohnehin.
- `internal/web/diagramm.go`: Balkendiagramme als SVG, vom Server
- `internal/web/chart.go`: Balkendiagramme als SVG, vom Server
ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute
(CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt.
Tooltip je Tag über `<title>`.
- `export.Exporter` merkt sich den letzten Lauf über alle Workspaces
(`MerkeLauf`, `LetzterLauf`); `export.Intervall` ersetzt die Konstante in
(`RecordRun`, `LastRun`); `export.Interval` ersetzt die Konstante in
`main.go`.
**Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und
@ -1827,9 +1827,9 @@ Munin; dafür gibt es das Log des Cronjobs und die Probe (4.11). Freier
Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je
Betriebssystem und ist ein klassischer Wert für das Monitoring.
Tests: `TestBestand`, `TestBelegung`, `TestAktivitaet`, `TestSchemastand`
(auch gegen PostgreSQL), `TestErhebung`, `TestMindestAbstand`,
`TestDiagramm`, `TestRunde125`, `TestGroesse`, `TestSystemseiteZustand`.
Tests: `TestInventory`, `TestDiskUsage`, `TestActivity`, `TestSchemaVersion`
(auch gegen PostgreSQL), `TestCollect`, `TestMinInterval`,
`TestChart`, `TestRound125`, `TestByteSize`, `TestSystemPageState`.
Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage.
### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16)
@ -1856,11 +1856,11 @@ Systemseite.
- **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen
Workspace.
Umsetzung: `internal/web/handlers_ueberblick.go`;
`statistik.Sammler.FuerWorkspace` (ohne Zwischenspeicher — nach einem neuen
Umsetzung: `internal/web/handlers_overview.go`;
`stats.Collector.ForWorkspace` (ohne Zwischenspeicher — nach einem neuen
Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt
in `partials.html`. Die Startseite des Workspace-Admins bleibt die
Projektliste. Test: `TestWorkspaceAdminUeberblick` (auch: Warnungen vor
Projektliste. Test: `TestWorkspaceAdminOverview` (auch: Warnungen vor
Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran).
### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16)
@ -1880,7 +1880,7 @@ verwies auf einen Eintrag „bezeichner“).
Verweis wäre schlimmer als ein überflüssiger.
- Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie
das nächste Mal geschrieben werden.
- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLaesstCodeStehen`.
- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLeavesCodeAlone`.
### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
@ -1922,7 +1922,7 @@ Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'
`keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`.
Die Hilfe setzt die Adresse schon ein (`serverURL` in
`internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData`
`internal/web/handlers_help.go`). Dieselbe Funktion für `keyListData`
verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in
@ -1931,7 +1931,7 @@ verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in
Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als
Einträgen deshalb als Text, in der Hilfe (`internal/help`) dagegen als
Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen,
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf
@ -1940,8 +1940,8 @@ Renderer laufen lassen.
**✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein
goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und
`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied:
`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
`help.Render` rufen nur noch dort hinein. Einziger Unterschied:
`RenderWithAnchors` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
soll nicht mit einer id der Seite selbst zusammenstoßen können.
`markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch
@ -1964,9 +1964,9 @@ Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein
ungespeicherter Text verloren geht.
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute
einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in
einfach fallen („zugnge-verwalten") —, sondern `anchorIDs` in
`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe
Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte`
Regel wie für Eintragsbezeichner. `TestHelpLinksPointToExistingSections`
liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und
Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
im Test, welcher Link dadurch ins Leere zeigen würde.
@ -1986,7 +1986,7 @@ ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine me
gibt, der aktuell gültigen. Der Zeilenvergleich selbst
(`internal/entry/diff.go`, `DiffLines`) ist eine klassische
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für
außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen
außergewöhnlich lange Einträge (`coarseDiff` oberhalb von vier Millionen
Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte

View file

@ -102,7 +102,7 @@ Load-Balancer oder Monitoring gehört an `/ready`.
| Meldung | Ursache | Abhilfe |
|---|---|---|
| `SESSION_SECRET ist nicht gesetzt` / `zu kurz` | Umgebungsvariable fehlt oder hat weniger als 32 Zeichen | Aus dem Passwortspeicher setzen. **Nicht** neu erzeugen, wenn es ein altes gibt, sonst sind alle Anmeldungen weg |
| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 16 Zeichen | dasselbe |
| `ADMIN_TOKEN ist nicht gesetzt` / `zu kurz` | dasselbe, mindestens 32 Zeichen | dasselbe |
| `Datenverzeichnis konnte nicht angelegt werden` | Volume fehlt oder gehört nicht dem Benutzer im Container (UID 10001) | `chown -R 10001:10001` auf das Volume, oder richtig einhängen |
| `unable to open database file`, `permission denied` | dasselbe für eine einzelne Datei, oft nach einem Zurückkopieren als root | Besitzer der Datei korrigieren |
| `address already in use` | Ein anderer Prozess belegt den Port | anderen Prozess beenden oder `PORT` ändern |

View file

@ -188,15 +188,15 @@ reproduzierbar.
jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf
liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe
ankommen. Abgesichert durch `internal/search/registry_test.go` und
`internal/mcpserver/gleichzeitig_test.go`.
`internal/mcpserver/concurrency_test.go`.
**Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die
Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie
auch, während der Index eines Projekts aus der Datenbank aufgebaut wurde. Nach
einem Neustart musste so jedes Projekt warten, bis das größte fertig
eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre.
Abgesichert durch `TestLangsamerAufbauBremstAndereProjekteNicht` und
`TestAenderungWaehrendDesAufbausGehtNichtVerloren`: Ein Schreibvorgang
Abgesichert durch `TestSlowBuildDoesNotBlockOtherProjects` und
`TestChangeDuringBuildIsNotLost`: Ein Schreibvorgang
während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand
danach nach.

View file

@ -339,8 +339,8 @@ und entscheidet:
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die
Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
Mensch ihn wieder kuratiert. Einzelheiten: `internal/dubletten` und
`internal/mcpserver/kuratiert.go`. Jeder Kandidat nennt die gemeinsamen Wörter
Mensch ihn wieder kuratiert. Einzelheiten: `internal/duplicates` und
`internal/mcpserver/curated.go`. Jeder Kandidat nennt die gemeinsamen Wörter
(`matched_words`).
### Gleichzeitiges Schreiben
@ -416,7 +416,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
einmal hinzufügen oder entfernen.
- **Gibt es schon?** Beim Anlegen zeigt Munin unter dem Titel vorhandene
Einträge mit ähnlichem Titel und die gemeinsamen Wörter — dieselbe Prüfung
wie bei `remember` über MCP (`internal/dubletten`), hier nur als Hinweis.
wie bei `remember` über MCP (`internal/duplicates`), hier nur als Hinweis.
- **Rückfrage beim Verlassen:** Wer im Formular etwas geändert hat und die
Seite verlässt, ohne zu speichern, bekommt eine Rückfrage des Browsers.
- **Auch verwandt:** Unter einem Eintrag stehen neben den Rückverweisen die
@ -445,7 +445,7 @@ Graph brauchen JavaScript. Alles andere funktioniert auch ohne.
| Variable | Standard | Bedeutung |
|---|---|---|
| `SESSION_SECRET` | — | **Pflicht.** Signiert Sitzungen und CSRF-Tokens. Mindestens 32 Zeichen. |
| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 16 Zeichen. |
| `ADMIN_TOKEN` | — | **Pflicht.** Zugang des Plattform-Admins. Mindestens 32 Zeichen. |
| `DB_DRIVER` | `sqlite` | Wo die Daten liegen: `sqlite` oder `postgres` (siehe AUSBAUSTUFEN.md 4.10) |
| `DATA_DIR` | `data` | Nur SQLite: Datenverzeichnis mit Kontroll-Datenbank (`control.db`) und `workspaces/` mit je einer Datei pro Workspace |
| `DATABASE_URL` | — | Nur PostgreSQL, dann **Pflicht**: z.B. `postgres://munin:geheim@host:5432/munin`. Schemas `munin_control` und je Workspace `munin_ws_<id>`. Lokal: `docker-compose.dev.yml` |
@ -799,10 +799,10 @@ scripts/backup.sh Sicherung aller Datenbankdateien
Eine Übersicht über jede einzelne Datei steht in [REPOMAP.md](REPOMAP.md).
**Hilfeseiten pflegen:** Die Hilfe unter `/hilfe` sind Markdown-Dateien in
`internal/hilfe/seiten/`, die mit ins Binary gebaut werden. Ändert sich eine
`internal/help/pages/`, die mit ins Binary gebaut werden. Ändert sich eine
Funktion, gehört die Anpassung der Hilfe in denselben Commit. Eine neue Seite
braucht eine Datei *und* einen Eintrag in der Liste `seiten` in
`internal/hilfe/hilfe.go`. Ein Test prüft, dass beides zusammenpasst und dass
braucht eine Datei *und* einen Eintrag in der Liste `pages` in
`internal/help/help.go`. Ein Test prüft, dass beides zusammenpasst und dass
alle Links auf `/hilfe/…` zu existierenden Seiten führen. Seiten mit
`NurAdmin: true` sieht nur der Plattform-Admin; allgemeine Seiten dürfen
nicht auf sie verlinken (auch das prüft ein Test). In den Seiten
@ -820,8 +820,8 @@ als statisches Binary ohne Laufzeitumgebung ausliefern.
Welche Datenbank, entscheidet `DB_DRIVER`. Alle Abfragen sind für beide
dieselben: Sie werden mit `?` geschrieben und laufen durch einen kleinen
Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialekt.go`,
`verbindung.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden
Dialekt, der für PostgreSQL `$1, $2 …` daraus macht (`internal/store/dialect.go`,
`pool.go`). Das SQL vermeidet deshalb alles, was nur eine der beiden
kann. Die Regeln stehen als Kommentar in
`internal/store/migrations/sqlite/001_init.sql`.
@ -839,8 +839,8 @@ Sie wird automatisch angewandt und vermerkt: für die Kontroll-Datenbank beim
Start, für einen Workspace beim ersten Öffnen. Bestehende Migrationen werden
nie verändert.
Zwei Tests passen auf: `TestMigrationsordnerGleich` (beide Ordner haben
dieselben Dateien, läuft immer) und `TestSchemaGleich` (beide ergeben dasselbe
Zwei Tests passen auf: `TestMigrationDirsMatch` (beide Ordner haben
dieselben Dateien, läuft immer) und `TestSchemaMatches` (beide ergeben dasselbe
Schema, braucht PostgreSQL):
```bash

View file

@ -2,6 +2,13 @@
Übersicht aller Dateien und Ordner mit ihrer Aufgabe.
**Sprache im Code:** Bezeichner (Pakete, Dateien, Typen, Funktionen,
Variablen, Felder, Tests, JS) sind englisch, ebenso Tabellen, Spalten,
Indizes und die Dateinamen der Migrationen. Kommentare, Doku, Hilfeseiten
und alle Texte der Weboberfläche (auch URLs wie `/hilfe`) bleiben deutsch.
Eine einmal ausgelieferte Migration wird nicht mehr umbenannt: Ihr
Dateiname steht als „angewendet“ in jeder bestehenden Datenbank.
```
munin/
├── main.go ← Einstiegspunkt. Liest die Konfiguration, prüft die
@ -9,12 +16,12 @@ munin/
│ Mindestlänge), öffnet Kontroll-Datenbank und
│ Workspace-Verwaltung (legt beim ersten Start den
│ Workspace "Intern" an), setzt die
│ Bestandteile zusammen (baueHandler, auch vom
│ Bestandteile zusammen (buildHandler, auch vom
│ Lasttest genutzt) und startet den Server.
│ Enthält außerdem die Server-Timeouts (gegen
│ Slowloris), die Body-Größenlimits und die
│ täglichen Aufgaben (Protokoll aufräumen,
│ Export), die über fuerJedenWorkspace alle
│ Export), die über forEachWorkspace alle
│ Workspaces erreichen. Tests: main_test.go,
│ Lasttest: last_test.go (nur mit -tags last).
│
@ -56,18 +63,18 @@ munin/
│ │ │ der Treiber modernc.org/sqlite ist: Er ist reines
│ │ │ Go und erlaubt damit ein statisches Binary.
│ │ │
│ │ ├── speicher.go ← Wo liegen die Daten? Interface Speicher, von
│ │ ├── backend.go ← Wo liegen die Daten? Interface Backend, von
│ │ │ main.go anhand von DB_DRIVER gewählt
│ │ │ (AUSBAUSTUFEN.md 4.10).
│ │ ├── speicher_sqlite.go ← Eine Datei je Workspace plus control.db (Standard).
│ │ ├── speicher_postgres.go← Ein Schema je Workspace (munin_ws_<id>) plus
│ │ ├── backend_sqlite.go ← Eine Datei je Workspace plus control.db (Standard).
│ │ ├── backend_postgres.go ← Ein Schema je Workspace (munin_ws_<id>) plus
│ │ │ munin_control. Feste Poolgrößen, Prüfung gegen
│ │ │ max_connections, Wiederholung beim Start,
│ │ │ Workspace-Name als Schema-Kommentar.
│ │ ├── dialekt.go ← Was sich zwischen SQLite und PostgreSQL
│ │ ├── dialect.go ← Was sich zwischen SQLite und PostgreSQL
│ │ │ unterscheidet: Platzhalter (? → $1) und
│ │ │ Migrationsordner. Bewusst klein.
│ │ ├── verbindung.go ← pool/transaktion: Jede Abfrage läuft hier durch
│ │ ├── pool.go ← pool/transaction: Jede Abfrage läuft hier durch
│ │ │ den Dialekt — vorbei kommt keine.
│ │ │
│ │ ├── control.go ← Die zentrale Kontroll-Datenbank (OpenControl):
@ -110,28 +117,28 @@ munin/
│ │ │ fremden Projekten
│ │ │ · LinkGraph liefert Knoten und Kanten für die
│ │ │ Graph-Ansicht
│ │ │ Jede lesende Abfrage trägt nichtImPapierkorb
│ │ │ Jede lesende Abfrage trägt notInTrash
│ │ │ (deleted_at IS NULL) — außer PutEntry, das
│ │ │ einen Eintrag im Papierkorb bewusst sieht und
│ │ │ ihn beim Überschreiben zurückholt.
│ │ │
│ │ ├── nutzung.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries
│ │ ├── usage.go ← Kennzahlen der Nutzungsübersicht: UsageSummaries
│ │ │ (Schreibvorgänge der letzten 30 Tage aus dem
│ │ │ Protokoll, Bestand aus entries — für ein oder
│ │ │ alle Projekte in zwei Abfragen), dazu
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
│ │ │ Keine eigene Zähltabelle.
│ │ │
│ │ ├── systemstand.go ← Kennzahlen eines ganzen Workspace für die
│ │ ├── sysinfo.go ← Kennzahlen eines ganzen Workspace für die
│ │ │ Systemseite: Bestand, Belegung (Dialekt-
│ │ │ Methode belegung), Aktivität je Tag,
│ │ │ Schemastand.
│ │ ├── suchprotokoll.go ← Was Agenten mit recall suchen (search_log):
│ │ ├── searchlog.go ← Was Agenten mit recall suchen (search_log):
│ │ │ LogSearch, SearchStatsFor, EmptySearches,
│ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll,
│ │ │ 30 Tage, verschwindet mit dem Projekt.
│ │ │
│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
│ │ ├── trash.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
│ │ │ (nur was schon im Papierkorb liegt) und
│ │ │ PurgeTrash für die tägliche Aufgabe. Die Frist
@ -167,7 +174,7 @@ munin/
│ │ ├── 001_init.sql ← Das Schema. Der Kopfkommentar nennt die Regeln
│ │ │ für portables SQL, an die sich alle weiteren
│ │ │ Migrationen halten sollen.
│ │ ├── 002_projekt_in_nebentabellen.sql
│ │ ├── 002_project_in_child_tables.sql
│ │ ← Führt project_id auch in entry_tags und
│ │ entry_links. Eine bewusste Redundanz gegen zwei
│ │ gemessene Bremsen: das Schlagwortmenü lief über
@ -175,34 +182,34 @@ munin/
│ │ und bei den Rückverweisen kippte durch ein
│ │ ORDER BY die Verknüpfungsreihenfolge
│ │ (3,1 ms statt 9 µs).
│ │ ├── 003_schreibbudget_je_projekt.sql
│ │ ├── 003_write_budget_per_project.sql
│ │ ← Spalte write_limit_hourly an projects. Vorgabe 0
│ │ heißt "keine eigene Angabe" — dann gilt der
│ │ Standard aus WRITE_LIMIT_HOURLY. So muss die
│ │ Migration keine Daten anfassen.
│ │ ├── 004_aenderungshistorie.sql
│ │ ├── 004_change_history.sql
│ │ ← Tabelle entry_versions für abgelöste Fassungen.
│ │ Wird mit dem Eintrag gelöscht (ON DELETE CASCADE).
│ │ ├── 005_papierkorb.sql
│ │ ├── 005_trash.sql
│ │ ← deleted_at/deleted_by an entries und ein
│ │ Teilindex nur über den Papierkorb. Löschen setzt
│ │ nur noch die Markierung; CASCADE greift erst
│ │ beim endgültigen Löschen.
│ │ ├── 006_suchprotokoll.sql
│ │ ├── 006_search_log.sql
│ │ ← Tabelle search_log (recall-Anfragen, 30 Tage).
│ │ └── 007_key_ablauf.sql
│ │ └── 007_key_expiry.sql
│ │ ← expires_at an api_keys. Leer = läuft nie ab;
│ │ abgelaufen gilt wie zurückgezogen.
│ │
│ ├── statistik/ ── Kennzahlen aller Workspaces ───────────────────
│ │ └── statistik.go ← Sammler für die Systemseite: Bestand, Platz,
│ │ └── stats.go ← Sammler für die Systemseite: Bestand, Platz,
│ │ Schema-Stand und Aktivität der letzten 30 Tage
│ │ über alle Workspaces. Rechnet beim Aufruf der
│ │ Seite, höchstens alle 5 Minuten neu.
│ │
│ ├── workspace/ ── Welche Datei gehört zu welcher Abteilung? ──────
│ │ └── workspace.go ← Manager öffnet Workspaces über den
│ │ store.Speicher bei Bedarf und hält sie offen,
│ │ store.Backend bei Bedarf und hält sie offen,
│ │ jeweils mit eigenem Suchindex. Datenbank und
│ │ Index gehören zusammen, damit kein Index
│ │ versehentlich an der falschen Datei hängt.
@ -257,7 +264,7 @@ munin/
│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen
│ │ Einträgen weicht er auf einen groben Vergleich
│ │ aus (grobDiff), damit Speicher und Rechenzeit
│ │ aus (coarseDiff), damit Speicher und Rechenzeit
│ │ begrenzt bleiben.
│ │
│ ├── markdown/ ── Markdown nach HTML, eine Regel für alles ───────
@ -265,7 +272,7 @@ munin/
│ │ Hilfe: GFM (Tabellen, Checklisten,
│ │ Durchstreichen, automatische Links), Roh-HTML
│ │ gesperrt. Nur die Hilfe bekommt Überschrift-
│ │ Anker (RenderMitAnkern) — mit gefalteten
│ │ Anker (RenderWithAnchors) — mit gefalteten
│ │ Umlauten über store.Slugify, damit die
│ │ "?"-Links der Oberfläche darauf zeigen können.
│ │
@ -287,7 +294,7 @@ munin/
│ │ Fehler ein halbes Archiv statt einer Fehlerseite.
│ │
│ ├── importer/ ── Gegenstück zum Export ──────────────────────────
│ │ ├── archiv.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
│ │ ├── archive.go ← Liest Markdown-Dateien aus einem ZIP-Archiv, mit
│ │ │ Grenzen für Anzahl und ausgepackte Größe.
│ │ │ Übergeht _index.md, versteckte Ordner, __MACOSX.
│ │ ├── parse.go ← Kleiner Frontmatter-Leser (kein YAML-Paket):
@ -305,7 +312,7 @@ munin/
│ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die
│ │ │ Weboberfläche.
│ │ │
│ │ ├── varianten.go ← Wortvarianten je Suchwort, schwächer gewichtet
│ │ ├── variants.go ← Wortvarianten je Suchwort, schwächer gewichtet
│ │ │ als ein exakter Treffer: Wortanfang ("deploy" →
│ │ │ Deployment), Endung ("deployen"), Teilwort
│ │ │ ("prozess" → Releaseprozess), Tippfehler.
@ -322,7 +329,7 @@ munin/
│ │ Aufbau nicht den ganzen Workspace aufhält.
│ │
│ ├── hilfe/ ── Eingebaute Onboarding-Hilfe ───────────────────
│ │ ├── hilfe.go ← Liste der Hilfeseiten (Reihenfolge, Titel,
│ │ ├── help.go ← Liste der Hilfeseiten (Reihenfolge, Titel,
│ │ │ Zielgruppe: alle / Admins / Plattform-Admin) und
│ │ │ das Rendern mit Überschrift-Ankern. Erklärt,
│ │ │ warum die Hilfe im Binary steckt statt als
@ -367,25 +374,25 @@ munin/
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
│ │ │ in server.go).
│ │ ├── handlers_nutzung.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
│ │ ├── handlers_usage.go ← Nutzungsseite je Projekt (/p/{projekt}/usage),
│ │ │ für jede Rolle. Die Admin-Projektliste nutzt
│ │ │ dieselben Zahlen (projectListItems).
│ │ ├── handlers_system.go ← /admin/system (Plattform-Admin): Kasten
│ │ │ "Zustand" (systemHinweise: Warnungen und
│ │ │ "Zustand" (systemHints: Warnungen und
│ │ │ Hinweise), Aktivität, Bestand, Speicher,
│ │ │ Bereitschaft, offene Verbindungen je Workspace
│ │ │ gegen das Budget, Fassung aus den Build-Infos.
│ │ ├── handlers_ueberblick.go
│ │ ├── handlers_overview.go
│ │ │ ← /admin/overview: Überblick des Workspace für
│ │ │ Workspace-Admins — Befunde je Projekt mit Link
│ │ │ (projektHinweise), Projekttabelle, Aktivität.
│ │ ├── diagramm.go ← Balkendiagramme der Systemseite als SVG, vom
│ │ │ (projectHints), Projekttabelle, Aktivität.
│ │ ├── chart.go ← Balkendiagramme der Systemseite als SVG, vom
│ │ │ Server ausgerechnet (kein JavaScript, CSP-fest).
│ │ ├── handlers_suche.go ← /admin/search: Suche in allen Projekten des
│ │ ├── handlers_search.go ← /admin/search: Suche in allen Projekten des
│ │ │ Workspace, nach Projekt gruppiert (Plattform-
│ │ │ und Workspace-Admin; Keys bleiben bei einem
│ │ │ Projekt).
│ │ ├── handlers_bereitschaft.go
│ │ ├── handlers_ready.go
│ │ │ ← /live (Prozess läuft, für den Container-Neustart)
│ │ │ und /ready (Datenbank antwortet, sonst 503; für
│ │ │ Load-Balancer). /healthz ist Alias für /live.
@ -398,12 +405,12 @@ munin/
│ │ ├── handlers_import.go ← ZIP-Upload für den Importer (/p/{projekt}/import),
│ │ │ nur mit Rolle admin. Läuft im Server, damit der
│ │ │ Suchindex im selben Zug nachgezogen wird.
│ │ ├── handlers_papierkorb.go
│ │ ├── handlers_trash.go
│ │ │ ← Papierkorb je Projekt: ansehen, wiederherstellen,
│ │ │ endgültig löschen, Filter nach löschendem Zugang.
│ │ │ Alles nur mit Schreibrecht, und bewusst nur hier
│ │ │ — über MCP gibt es kein Wiederherstellen.
│ │ ├── handlers_hilfe.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe
│ │ ├── handlers_help.go ← Zeigt die eingebauten Hilfeseiten unter /hilfe
│ │ │ für jede Sitzung. Setzt vorher Server-Adresse
│ │ │ und Projekt-Bezeichner in die Beispiele ein
│ │ │ (serverURL, mit geprüftem Host-Header).
@ -426,7 +433,7 @@ munin/
│ │ Speichern ohne Änderung überschreibt nichts.
│ │
│ ├── dubletten/ ── Gibt es schon? ──────────────────────────────────
│ │ └── dubletten.go ← Vor dem Anlegen: Gibt es einen Eintrag mit
│ │ └── duplicates.go ← Vor dem Anlegen: Gibt es einen Eintrag mit
│ │ ähnlichem Titel? (Pruefe) Gemeinsames Präfix
│ │ zählt nicht, Suchbegriffe nur wörtlich, die
│ │ gemeinsamen Wörter kommen mit. Genutzt von
@ -441,7 +448,7 @@ munin/
│ │ Qualität der Antworten, weil er bei jedem Client
│ │ im Kontext steht.
│ │
│ ├── kuratiert.go ← Kuratierte Einträge ändert remember nur mit
│ ├── curated.go ← Kuratierte Einträge ändert remember nur mit
│ │ override_curated und Begründung; die Änderung
│ │ landet auf der Nutzungsseite zur Prüfung.
│ │
@ -473,12 +480,12 @@ munin/
│ │ Knoten auseinander, Namen nur, wo Platz ist.
│ │ Texte nur über textContent, nie innerHTML.
│ ├── tagfilter.js ← Filterfeld auf der Seite "Alle Schlagworte".
│ ├── aehnlich.js ← "Gibt es schon?" unter dem Titel eines neuen
│ ├── similar.js ← "Gibt es schon?" unter dem Titel eines neuen
│ │ Eintrags, fragt nach einer Tipp-Pause.
│ ├── ungespeichert.js ← Rückfrage beim Verlassen eines Formulars mit
│ │ ungespeicherten Änderungen (data-ungespeichert).
│ ├── tastenkuerzel.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter
│ │ speichern. Ziel steht als data-kuerzel im
│ ├── unsaved.js ← Rückfrage beim Verlassen eines Formulars mit
│ │ ungespeicherten Änderungen (data-unsaved).
│ ├── shortcuts.js ← "/" Suche, "n" neu, "e" bearbeiten, Strg+Enter
│ │ speichern. Ziel steht als data-shortcut im
│ │ Template, die Rechte also dort.
│ └── img/ ← Logo in mehreren Fassungen (hell, dunkel, ohne
│ Hintergrund). Die Anmeldeseite wählt je nach
@ -525,14 +532,14 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
| Datei | Schwerpunkt |
|---|---|
| `internal/auth/auth_test.go` | Signatur, Manipulation, Ablauf, CSRF, Key-Form, Rollen, Cookie-Flags, Rate-Limiting, X-Forwarded-For |
| `internal/store/systemstand_test.go` | Bestand samt Key-Regeln, Belegung wächst mit dem Inhalt, Aktivität je Tag und Weg mit Zeitgrenze, Schemastand (auch gegen PostgreSQL) |
| `internal/statistik/statistik_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten |
| `internal/web/diagramm_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar |
| `internal/store/nutzung_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge |
| `internal/store/papierkorb_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist |
| `internal/store/sysinfo_test.go` | Bestand samt Key-Regeln, Belegung wächst mit dem Inhalt, Aktivität je Tag und Weg mit Zeitgrenze, Schemastand (auch gegen PostgreSQL) |
| `internal/stats/stats_test.go` | Summen über zwei Workspaces, alle 30 Tage gefüllt, aktive und ruhige Projekte, Zwischenspeicher für 5 Minuten |
| `internal/web/chart_test.go` | Balken gestapelt von der Nulllinie, Skala 1-2-5, kleine Werte sichtbar, Tooltip, Bytes lesbar |
| `internal/store/usage_test.go` | Zählung nach Art und Weg samt Zeitfenster, Bestand ohne Papierkorb, Verweise ins Leere, aktive Zugänge |
| `internal/store/trash_test.go` | **Jeder lesende Weg blendet den Papierkorb aus** (Einzelabruf, Liste mit Filtern, Suchindex/Export, Zählen, Schlagworte, Rückverweise, Graph, Sammelbearbeitung); Wiederherstellen, Zurückholen durch Überschreiben, Konflikt, endgültiges Löschen, Frist |
| `internal/store/store_test.go` | Migrationen, Slug-Prüfung, Abschottung, Dublettenvermeidung, optimistisches Sperren, Verweise, Protokoll, Änderungshistorie |
| `internal/store/control_test.go` | Kontroll-Datenbank: Standard-Workspace bleibt stabil, Auflistung, Zuordnung Key → Workspace |
| `internal/store/dialekt_test.go` | Platzhalter-Umwandlung (Text, Kommentare, Bezeichner), **beide Migrationsordner gleich**, Schemanamen, Textkonstanten |
| `internal/store/dialect_test.go` | Platzhalter-Umwandlung (Text, Kommentare, Bezeichner), **beide Migrationsordner gleich**, Schemanamen, Textkonstanten |
| `internal/store/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: **Schema-Vergleich SQLite ↔ PostgreSQL**, Schema je Workspace samt Kommentar, Abschottung, Verbindungsbudget. Schaltet außerdem `testStore`/`testControl` auf PostgreSQL um |
| `internal/importer/postgres_test.go` | Nur mit `MUNIN_TEST_PG`: Export als ZIP aus einem PostgreSQL-Workspace und Import in einen zweiten, Einträge danach gleich |
| `internal/workspace/workspace_test.go` | Manager hält Dateien offen, trennt Workspaces, schließt sauber |
@ -540,21 +547,21 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
| `internal/search/search_test.go` | Bewertung, Wortanfang, Aktualisieren und Entfernen |
| `internal/search/varianten_test.go` | Wortanfang neben exaktem Treffer, Komposita, Endungen, Stoppwörter, Tippfehler nur in SearchFuzzy |
| `internal/search/variants_test.go` | Wortanfang neben exaktem Treffer, Komposita, Endungen, Stoppwörter, Tippfehler nur in SearchFuzzy |
| `internal/search/bench_test.go` | Laufzeit einer Suche bei 10.000 Einträgen und 20.000 Wörtern |
| `internal/search/registry_test.go` | Index folgt der Datenbank, auch wenn sich Schreiber überholen, ein Eintrag gleich wieder gelöscht wird oder während des Aufbaus geschrieben wird; ein langsamer Aufbau bremst andere Projekte nicht |
| `internal/mcpserver/gleichzeitig_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell |
| `internal/mcpserver/concurrency_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell |
| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen |
| `internal/importer/importer_test.go` | **Rundlauf Export → ZIP → Import ohne Verlust**, auch mit dem ZIP aus dem Download, Vorschau, Überspringen/Überschreiben, Papierkorb, doppelte Bezeichner, fremde Köpfe, Grenzen des Archivs |
| `internal/export/export_test.go` | Dateikopf und YAML-Maskierung, Aufräumen verwaister Dateien, Stabilität über mehrere Läufe |
| `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen |
| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route |
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse, `/live` und `/ready` (503 ohne Datenbank, ohne Einzelheiten) |
| `internal/web/oberflaeche_test.go` | Veraltete Einträge (Marke, Filter, Nutzungsseite), „Auch verwandt“, „Gibt es schon?“ (auch: nicht mit Leserecht, nicht bei vorhandenem Bezeichner), Formular-Attribute und Kürzel je Rolle, Protokollfilter, Systemseite (nur Plattform-Admin), **Ablaufdatum für Keys** (Ende der Sitzung), Suche über alle Projekte (nicht für Projektzugänge) |
| `internal/dubletten/dubletten_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt |
| `internal/web/ui_test.go` | Veraltete Einträge (Marke, Filter, Nutzungsseite), „Auch verwandt“, „Gibt es schon?“ (auch: nicht mit Leserecht, nicht bei vorhandenem Bezeichner), Formular-Attribute und Kürzel je Rolle, Protokollfilter, Systemseite (nur Plattform-Admin), **Ablaufdatum für Keys** (Ende der Sitzung), Suche über alle Projekte (nicht für Projektzugänge) |
| `internal/duplicates/duplicates_test.go` | Wann ein gemeinsames Titel-Präfix aus dem Vergleich fällt |
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung (gleicher Bezeichner und ähnlicher Titel), Synonyme, Suchprotokoll, Konflikte, Rollen, **Projektabschottung** |
| `internal/store/suchprotokoll_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt |
| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
| `internal/store/searchlog_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt |
| `internal/help/help_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
## Datenfluss: Agent ruft Wissen ab
@ -570,10 +577,10 @@ KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall",
→ mcpserver/tools.go (recall)
→ search/registry.go Index dieses Projekts (beim ersten Mal
aus der Datenbank aufgebaut)
→ search/index.go (Search) BM25 über Wortvarianten (varianten.go)
→ search/index.go (Search) BM25 über Wortvarianten (variants.go)
→ store/entries.go (EntryBySlug) Treffer nachladen
→ entry/entry.go (Snippet) Textausschnitt je Treffer
→ store/suchprotokoll.go (LogSearch) Anfrage und Trefferzahl
→ store/searchlog.go (LogSearch) Anfrage und Trefferzahl
→ JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen
```
@ -586,7 +593,7 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c
→ requireWrite() Rolle read wird hier abgewiesen
→ dubletten (Pruefe) neuer Bezeichner, ähnlicher Titel?
→ candidates zurück, nichts schreiben
→ kuratiert.go (istKuratiert) kuratiert und kein override_curated?
→ curated.go (isCurated) kuratiert und kein override_curated?
→ judgment_required, nichts schreiben
→ store/entries.go (PutEntry) eine Transaktion:
· Bezeichner vorhanden? → aktualisieren

View file

@ -21,7 +21,7 @@ services:
postgres:
image: postgres:18
# Munin braucht je Workspace bis zu 3 Verbindungen (siehe
# internal/store/speicher_postgres.go). 200 reichen für rund 60 Workspaces.
# internal/store/backend_postgres.go). 200 reichen für rund 60 Workspaces.
command: postgres -c max_connections=200
environment:
POSTGRES_USER: munin

View file

@ -15,9 +15,9 @@ var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests
// ── Sitzungen ────────────────────────────────────────────────────────────────
func TestSessionRoundTrip(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
cookie := testSigner.SignSession("key:abc123", time.Now().Add(time.Hour))
subject, ok := testSigner.VerifySession(cookie)
subject, _, ok := testSigner.VerifySession(cookie)
if !ok {
t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt")
}
@ -29,10 +29,10 @@ func TestSessionRoundTrip(t *testing.T) {
// TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige
// Geheimnis darf sich niemand eine Sitzung ausstellen können.
func TestSessionRejectsWrongSecret(t *testing.T) {
cookie := testSigner.SignSession(SubjectPlatformAdmin)
fremder := NewSigner([]byte("ein-voellig-anderes-secret"))
cookie := testSigner.SignSession(SubjectPlatformAdmin, time.Now().Add(time.Hour))
foreign := NewSigner([]byte("ein-voellig-anderes-secret"))
if _, ok := fremder.VerifySession(cookie); ok {
if _, _, ok := foreign.VerifySession(cookie); ok {
t.Error("Sitzung wurde mit falschem Secret akzeptiert")
}
}
@ -41,18 +41,45 @@ func TestSessionRejectsWrongSecret(t *testing.T) {
// auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform"
// ersetzen und wieder einsetzen.
func TestSessionRejectsTampering(t *testing.T) {
cookie := testSigner.SignSession("key:abc123")
cookie := testSigner.SignSession("key:abc123", time.Now().Add(time.Hour))
_, signature, _ := strings.Cut(cookie, ".")
gefaelscht := base64.RawURLEncoding.EncodeToString(
forged := base64.RawURLEncoding.EncodeToString(
fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()),
)
if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok {
if _, _, ok := testSigner.VerifySession(forged + "." + signature); ok {
t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht")
}
}
// TestSessionTTLPerKind prüft die Obergrenze je Sitzungsart: Ein
// Plattform-Admin-Cookie darf höchstens PlatformAdminSessionTTL gelten. Länger
// laufende (etwa noch mit den früheren 30 Tagen ausgestellte) werden
// abgelehnt, obwohl sie korrekt signiert sind. Key-Sitzungen behalten 30 Tage.
func TestSessionTTLPerKind(t *testing.T) {
platform := PlatformSubject("abcdef0123456789", "")
now := time.Now()
cases := []struct {
subject string
expiry time.Time
want bool
}{
{platform, now.Add(PlatformAdminSessionTTL), true},
{platform, now.Add(30 * 24 * time.Hour), false},
{"key:ws:abc123", now.Add(SessionTTL), true},
{"wsadmin:ws:abc123", now.Add(SessionTTL), true},
{"key:ws:abc123", now.Add(SessionTTL + time.Hour), false},
}
for _, tc := range cases {
_, _, ok := testSigner.VerifySession(testSigner.SignSession(tc.subject, tc.expiry))
if ok != tc.want {
t.Errorf("%s, gültig noch %v: ok = %v, erwartet %v", tc.subject, tc.expiry.Sub(now).Round(time.Hour), ok, tc.want)
}
}
}
// TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in
// der Vergangenheit und signiert sie korrekt — so wird wirklich nur die
// Ablaufprüfung getestet, ohne eine Stunde zu warten.
@ -60,7 +87,7 @@ func TestSessionRejectsExpired(t *testing.T) {
payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix())
cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload)
if _, ok := testSigner.VerifySession(cookie); ok {
if _, _, ok := testSigner.VerifySession(cookie); ok {
t.Error("abgelaufene Sitzung wurde akzeptiert")
}
}
@ -78,7 +105,7 @@ func TestSessionRejectsMalformed(t *testing.T) {
}
for _, cookie := range cases {
if _, ok := testSigner.VerifySession(cookie); ok {
if _, _, ok := testSigner.VerifySession(cookie); ok {
t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie)
}
}
@ -115,7 +142,7 @@ func TestTokenDomainSeparation(t *testing.T) {
subject := "key:abc123"
csrf := testSigner.CSRFToken(subject)
if _, ok := testSigner.VerifySession(
if _, _, ok := testSigner.VerifySession(
base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf,
); ok {
t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert")
@ -139,16 +166,16 @@ func TestGenerateKeyFormat(t *testing.T) {
// TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist.
func TestGenerateKeyIsUnique(t *testing.T) {
gesehen := make(map[string]bool)
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
key, err := GenerateKey()
if err != nil {
t.Fatal(err)
}
if gesehen[key] {
if seen[key] {
t.Fatal("zweimal derselbe Key erzeugt")
}
gesehen[key] = true
seen[key] = true
}
}
@ -169,10 +196,10 @@ func TestHashKey(t *testing.T) {
}
func TestLooksLikeKey(t *testing.T) {
gueltig := KeyPrefix + strings.Repeat("ab", 32)
valid := KeyPrefix + strings.Repeat("ab", 32)
cases := map[string]bool{
gueltig: true,
valid: true,
"": false,
"wk_": false,
strings.Repeat("ab", 32): false, // ohne Präfix
@ -180,9 +207,9 @@ func TestLooksLikeKey(t *testing.T) {
KeyPrefix + strings.Repeat("g", 64): false, // kein Hex
}
for key, erwartet := range cases {
if got := LooksLikeKey(key); got != erwartet {
t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet)
for key, want := range cases {
if got := LooksLikeKey(key); got != want {
t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, want)
}
}
}
@ -247,10 +274,10 @@ func TestRolePermissions(t *testing.T) {
func TestCookiePolicySecure(t *testing.T) {
cases := []struct {
name string
env string
host string
erwartet bool
name string
env string
host string
want bool
}{
{"Standard, echte Domain", "", "munin.intern", true},
{"Standard, localhost", "", "localhost:8080", false},
@ -265,8 +292,8 @@ func TestCookiePolicySecure(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil)
r.Host = tc.host
if got := policy.Secure(r); got != tc.erwartet {
t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet)
if got := policy.Secure(r); got != tc.want {
t.Errorf("Secure = %v, erwartet %v", got, tc.want)
}
})
}
@ -344,19 +371,19 @@ func TestRateLimiterResetClearsCounter(t *testing.T) {
func TestWriteBudgetAllowsUpToLimit(t *testing.T) {
budget := NewWriteBudget(3, time.Hour)
const projekt = "p1"
const project = "p1"
for i := 1; i <= 3; i++ {
ok, _, ueber := budget.Consume(projekt, 0)
ok, _, over := budget.Consume(project, 0)
if !ok {
t.Fatalf("Aufruf %d von 3 abgelehnt", i)
}
if ueber != 0 {
t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber)
if over != 0 {
t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, over)
}
}
ok, retryAfter, ueber := budget.Consume(projekt, 0)
ok, retryAfter, over := budget.Consume(project, 0)
if ok {
t.Error("der vierte Aufruf wurde angenommen")
}
@ -365,12 +392,12 @@ func TestWriteBudgetAllowsUpToLimit(t *testing.T) {
}
// Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die
// Überschreitung nur einmal je Fenster protokolliert wird.
if ueber != 1 {
t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber)
if over != 1 {
t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", over)
}
if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 {
t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber)
if _, _, over := budget.Consume(project, 0); over != 2 {
t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", over)
}
}
@ -418,20 +445,20 @@ func TestWriteBudgetZeroDefaultDisables(t *testing.T) {
func TestWriteBudgetRemainingCountsDown(t *testing.T) {
budget := NewWriteBudget(5, time.Hour)
if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 {
t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze)
if free, limit := budget.Remaining("p1", 0); free != 5 || limit != 5 {
t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", free, limit)
}
budget.Consume("p1", 0)
budget.Consume("p1", 0)
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei)
if free, _ := budget.Remaining("p1", 0); free != 3 {
t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", free)
}
// Remaining selbst darf nichts verbrauchen.
if frei, _ := budget.Remaining("p1", 0); frei != 3 {
t.Errorf("frei = %d — Remaining hat mitgezählt", frei)
if free, _ := budget.Remaining("p1", 0); free != 3 {
t.Errorf("frei = %d — Remaining hat mitgezählt", free)
}
}
@ -511,16 +538,16 @@ func TestSetTrustedProxiesRejectsGarbage(t *testing.T) {
SetTrustedProxies("")
}
func TestPlatformSubjectHinUndZurueck(t *testing.T) {
faelle := []struct{ fingerabdruck, workspaceID string }{
func TestPlatformSubjectRoundTrip(t *testing.T) {
cases := []struct{ fingerprint, workspaceID string }{
{"abc123", ""},
{"abc123", "ws-1"},
}
for _, f := range faelle {
subject := PlatformSubject(f.fingerabdruck, f.workspaceID)
for _, f := range cases {
subject := PlatformSubject(f.fingerprint, f.workspaceID)
fp, ws, ok := ParsePlatformSubject(subject)
if !ok || fp != f.fingerabdruck || ws != f.workspaceID {
t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID)
if !ok || fp != f.fingerprint || ws != f.workspaceID {
t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerprint, f.workspaceID)
}
}
@ -532,7 +559,7 @@ func TestPlatformSubjectHinUndZurueck(t *testing.T) {
}
}
func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) {
func TestFingerprintDependsOnSecret(t *testing.T) {
a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert")
b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert")
if a == b {

View file

@ -41,6 +41,23 @@ func (s *Signer) ValidCSRFToken(token, subject string) bool {
}
// CSRFTokenFromRequest holt das Token aus dem Formular oder dem Header.
// SignHint signiert eine Erfolgsmeldung, die über einen Redirect in der
// Adresszeile mitreist (siehe withHint in internal/web). Ohne Signatur ließe
// sich mit einem präparierten Link ein beliebiger Text als Meldung in die
// Oberfläche schmuggeln — etwa „Bitte den Key an … schicken".
//
// Das Präfix "hint:" trennt diese Signaturen von Sitzungen und CSRF-Tokens:
// Eine signierte Meldung taugt nie als gültige Sitzung oder umgekehrt.
func (s *Signer) SignHint(message string) string {
return s.sign("hint:" + message)
}
// ValidHint prüft, ob signature zu message passt — in konstanter Zeit, wie bei
// den anderen Vergleichen mit Geheimnissen.
func (s *Signer) ValidHint(message, signature string) bool {
return subtle.ConstantTimeCompare([]byte(signature), []byte(s.SignHint(message))) == 1
}
func CSRFTokenFromRequest(r *http.Request) string {
if token := r.Header.Get(CSRFHeaderName); token != "" {
return token

View file

@ -14,98 +14,98 @@ import (
// ── Der gemeinsame Unterbau ──────────────────────────────────────────────────
// fensterStand ist der Zählerstand eines Schlüssels im laufenden Fenster.
type fensterStand struct {
anzahl int
fensterBis time.Time
// windowState ist der Zählerstand eines Schlüssels im laufenden Fenster.
type windowState struct {
count int
windowEnd time.Time
}
// fensterZaehler zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters.
// windowCounter zählt Ereignisse je Schlüssel innerhalb eines Zeitfensters.
//
// Was der Schlüssel bedeutet, entscheidet der Benutzer des Zählers: beim
// RateLimiter ist es eine IP-Adresse, beim WriteBudget eine Projekt-ID. Für den
// Zähler ist beides nur eine Zeichenkette — deshalb lässt sich dieselbe
// Mechanik für beides verwenden.
type fensterZaehler struct {
mu sync.Mutex
fenster time.Duration
staende map[string]*fensterStand
type windowCounter struct {
mu sync.Mutex
window time.Duration
states map[string]*windowState
}
// neuerZaehler erstellt einen Zähler und startet einen Hintergrund-Job, der
// newCounter erstellt einen Zähler und startet einen Hintergrund-Job, der
// abgelaufene Einträge regelmäßig entfernt.
//
// Ohne das Aufräumen würde die Map unbegrenzt wachsen: Jede IP, die einmal
// vorbeigeschaut hat, bliebe für immer darin stehen — bei Scannern und Bots sind
// das schnell sehr viele.
func neuerZaehler(fenster time.Duration) *fensterZaehler {
z := &fensterZaehler{
fenster: fenster,
staende: make(map[string]*fensterStand),
func newCounter(window time.Duration) *windowCounter {
z := &windowCounter{
window: window,
states: make(map[string]*windowState),
}
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
z.aufraeumen()
z.cleanup()
}
}()
return z
}
// lies gibt den aktuellen Stand zurück, ohne zu zählen.
// restlaufzeit ist die verbleibende Dauer des Fensters.
func (z *fensterZaehler) lies(schluessel string) (anzahl int, restlaufzeit time.Duration) {
// peek gibt den aktuellen Stand zurück, ohne zu zählen.
// remaining ist die verbleibende Dauer des Fensters.
func (z *windowCounter) peek(key string) (count int, remaining time.Duration) {
z.mu.Lock()
defer z.mu.Unlock()
stand, vorhanden := z.staende[schluessel]
state, existing := z.states[key]
now := time.Now()
// Kein Eintrag oder Fenster abgelaufen: der Schlüssel steht bei null.
if !vorhanden || now.After(stand.fensterBis) {
if !existing || now.After(state.windowEnd) {
return 0, 0
}
return stand.anzahl, stand.fensterBis.Sub(now)
return state.count, state.windowEnd.Sub(now)
}
// zaehle erhöht den Zähler um eins und gibt den neuen Stand zurück.
func (z *fensterZaehler) zaehle(schluessel string) (anzahl int, restlaufzeit time.Duration) {
// count erhöht den Zähler um eins und gibt den neuen Stand zurück.
func (z *windowCounter) count(key string) (count int, remaining time.Duration) {
z.mu.Lock()
defer z.mu.Unlock()
now := time.Now()
stand, vorhanden := z.staende[schluessel]
state, existing := z.states[key]
// Ein abgelaufenes Fenster wird nicht fortgeschrieben, sondern durch ein
// frisches ersetzt — sonst würde der alte Stand weitergezählt.
if !vorhanden || now.After(stand.fensterBis) {
stand = &fensterStand{fensterBis: now.Add(z.fenster)}
z.staende[schluessel] = stand
if !existing || now.After(state.windowEnd) {
state = &windowState{windowEnd: now.Add(z.window)}
z.states[key] = state
}
stand.anzahl++
return stand.anzahl, stand.fensterBis.Sub(now)
state.count++
return state.count, state.windowEnd.Sub(now)
}
// vergiss löscht den Zähler eines Schlüssels.
func (z *fensterZaehler) vergiss(schluessel string) {
// forget löscht den Zähler eines Schlüssels.
func (z *windowCounter) forget(key string) {
z.mu.Lock()
defer z.mu.Unlock()
delete(z.staende, schluessel)
delete(z.states, key)
}
// aufraeumen entfernt abgelaufene Einträge aus der Map.
func (z *fensterZaehler) aufraeumen() {
// cleanup entfernt abgelaufene Einträge aus der Map.
func (z *windowCounter) cleanup() {
z.mu.Lock()
defer z.mu.Unlock()
now := time.Now()
for schluessel, stand := range z.staende {
if now.After(stand.fensterBis) {
delete(z.staende, schluessel)
for key, state := range z.states {
if now.After(state.windowEnd) {
delete(z.states, key)
}
}
}
@ -121,32 +121,32 @@ const (
// ohne das könnte ein Angreifer beliebig oft Passwörter und API-Keys
// durchprobieren.
type RateLimiter struct {
zaehler *fensterZaehler
counter *windowCounter
}
// NewRateLimiter erstellt einen Rate-Limiter für Anmeldeversuche.
func NewRateLimiter() *RateLimiter {
return &RateLimiter{zaehler: neuerZaehler(rateLimitWindow)}
return &RateLimiter{counter: newCounter(rateLimitWindow)}
}
// Allow prüft ob die IP noch einen Versuch frei hat, ohne ihn zu verbrauchen.
// retryAfter gibt an wie lange bei einer Sperre noch gewartet werden muss.
func (l *RateLimiter) Allow(ip string) (ok bool, retryAfter time.Duration) {
anzahl, restlaufzeit := l.zaehler.lies(ip)
if anzahl >= rateLimitMax {
return false, restlaufzeit
count, remaining := l.counter.peek(ip)
if count >= rateLimitMax {
return false, remaining
}
return true, 0
}
// RecordFailure zählt einen fehlgeschlagenen Anmeldeversuch für die IP.
func (l *RateLimiter) RecordFailure(ip string) {
l.zaehler.zaehle(ip)
l.counter.count(ip)
}
// Reset löscht den Zähler einer IP nach einem erfolgreichen Zugriff.
func (l *RateLimiter) Reset(ip string) {
l.zaehler.vergiss(ip)
l.counter.forget(ip)
}
// ── Schreibbudget je Projekt ─────────────────────────────────────────────────
@ -175,7 +175,7 @@ func (l *RateLimiter) Reset(ip string) {
// Umgehen ein. Für eine verbindliche Zusage müsste der Zähler in die Datenbank —
// siehe AUSBAUSTUFEN.md. Solange genau ein Prozess läuft, reicht das hier.
type WriteBudget struct {
zaehler *fensterZaehler
counter *windowCounter
standard int
}
@ -183,9 +183,9 @@ type WriteBudget struct {
//
// standard gilt für alle Projekte, die keine eigene Obergrenze gesetzt haben.
// Ein Wert von 0 oder kleiner schaltet die Begrenzung vollständig ab.
func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget {
func NewWriteBudget(standard int, window time.Duration) *WriteBudget {
return &WriteBudget{
zaehler: neuerZaehler(fenster),
counter: newCounter(window),
standard: standard,
}
}
@ -193,7 +193,7 @@ func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget {
// Consume verbucht einen Schreibvorgang für ein Projekt und sagt, ob er noch ins
// Budget passte.
//
// projektLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard
// projectLimit ist die eigene Obergrenze des Projekts; bei 0 gilt der Standard
// aus NewWriteBudget. Die Obergrenze wird bei jedem Aufruf mitgegeben statt im
// Budget gespeichert — so wirkt eine Änderung in der Verwaltung sofort, ohne
// dass hier irgendein zwischengespeicherter Wert veralten könnte.
@ -202,48 +202,48 @@ func NewWriteBudget(standard int, fenster time.Duration) *WriteBudget {
// weiterversucht, das Fenster nie volllaufen lassen — und der Zählerstand wäre
// keine ehrliche Auskunft darüber, wie viel gerade los ist.
//
// ueberschreitung sagt, der wievielte Aufruf über der Grenze das war: 0 solange
// excess sagt, der wievielte Aufruf über der Grenze das war: 0 solange
// alles im Rahmen ist, 1 beim allerersten abgelehnten Aufruf, dann aufsteigend.
//
// Dieser dritte Rückgabewert sieht nach Ballast aus, hat aber einen konkreten
// Zweck: Der Aufrufer soll die Überschreitung protokollieren können, ohne bei
// JEDEM abgelehnten Aufruf eine Zeile zu schreiben. Ein Agent in einer Schleife
// erzeugt sonst genau die Datenflut, die das Budget verhindern soll — nur eben
// im Protokoll statt in den Einträgen. Mit ueberschreitung == 1 als Bedingung
// im Protokoll statt in den Einträgen. Mit excess == 1 als Bedingung
// entsteht je Fenster genau ein Vermerk.
func (b *WriteBudget) Consume(projektID string, projektLimit int) (ok bool, retryAfter time.Duration, ueberschreitung int) {
grenze := b.grenzeFuer(projektLimit)
if grenze <= 0 {
func (b *WriteBudget) Consume(projectID string, projectLimit int) (ok bool, retryAfter time.Duration, excess int) {
limit := b.limitFor(projectLimit)
if limit <= 0 {
return true, 0, 0 // Begrenzung abgeschaltet
}
anzahl, restlaufzeit := b.zaehler.zaehle(projektID)
if anzahl > grenze {
return false, restlaufzeit, anzahl - grenze
count, remaining := b.counter.count(projectID)
if count > limit {
return false, remaining, count - limit
}
return true, 0, 0
}
// Remaining sagt, wie viele Schreibvorgänge im laufenden Fenster noch frei sind,
// ohne etwas zu verbrauchen. Für die Anzeige in der Verwaltung.
func (b *WriteBudget) Remaining(projektID string, projektLimit int) (frei int, grenze int) {
grenze = b.grenzeFuer(projektLimit)
if grenze <= 0 {
func (b *WriteBudget) Remaining(projectID string, projectLimit int) (free int, limit int) {
limit = b.limitFor(projectLimit)
if limit <= 0 {
return 0, 0
}
anzahl, _ := b.zaehler.lies(projektID)
frei = grenze - anzahl
if frei < 0 {
frei = 0
count, _ := b.counter.peek(projectID)
free = limit - count
if free < 0 {
free = 0
}
return frei, grenze
return free, limit
}
// grenzeFuer löst die geltende Obergrenze auf: erst das Projekt, dann der Standard.
func (b *WriteBudget) grenzeFuer(projektLimit int) int {
if projektLimit > 0 {
return projektLimit
// limitFor löst die geltende Obergrenze auf: erst das Projekt, dann der Standard.
func (b *WriteBudget) limitFor(projectLimit int) int {
if projectLimit > 0 {
return projectLimit
}
return b.standard
}

View file

@ -20,8 +20,29 @@ import (
const SessionCookieName = "munin_session"
// SessionTTL bestimmt, wie lange eine Sitzung ohne erneute Anmeldung gilt.
//
// 30 Tage sind vertretbar, weil sich diese Sitzungen jederzeit beenden lassen:
// Bei Key und Workspace-Admin prüft der Server bei jedem Request in der
// Datenbank, ob der Zugang noch gilt — ein Widerruf wirkt sofort, auch auf
// einen gestohlenen Cookie.
const SessionTTL = 30 * 24 * time.Hour
// PlatformAdminSessionTTL gilt für die Sitzung des Plattform-Admins.
//
// Kürzer, weil es hier keinen Widerruf in der Datenbank gibt: Ein gestohlener
// Cookie bliebe sonst bis zum Ablauf gültig — oder bis jemand das ADMIN_TOKEN
// tauscht. Und der Plattform-Admin kommt an alles.
const PlatformAdminSessionTTL = 12 * time.Hour
// SessionTTLFor gibt die Laufzeit zurück, die eine Sitzung für dieses Subject
// höchstens haben darf.
func SessionTTLFor(subject string) time.Duration {
if strings.HasPrefix(subject, SubjectPlatformAdmin+"/") {
return PlatformAdminSessionTTL
}
return SessionTTL
}
// SubjectPlatformAdmin ist der Anfang des Session-Subjects des Plattform-Admins
// (ADMIN_TOKEN). Das vollständige Subject baut PlatformSubject.
const SubjectPlatformAdmin = "platform"
@ -69,43 +90,62 @@ func (s *Signer) sign(payload string) string {
// wer ihn stiehlt, bekommt eine Sitzung, aber kein dauerhaftes Zugangsmittel für
// die API. Und ob der zugehörige Key überhaupt noch gültig ist, prüft der Server
// bei jedem Request neu in der Datenbank (siehe internal/web).
func (s *Signer) SignSession(subject string) string {
expiry := time.Now().Add(SessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", subject, expiry)
//
// expiry ist der Ablaufzeitpunkt. Bei einer Anmeldung ist das jetzt plus
// SessionTTLFor(subject); wechselt der Plattform-Admin nur den Workspace,
// bleibt der bisherige Zeitpunkt (siehe setSessionCookie in internal/web).
func (s *Signer) SignSession(subject string, expiry time.Time) string {
payload := fmt.Sprintf("%s|%d", subject, expiry.Unix())
encoded := base64.RawURLEncoding.EncodeToString([]byte(payload))
return encoded + "." + s.sign(payload)
}
// VerifySession prüft Signatur und Ablaufdatum und gibt das Subject zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, ok bool) {
// VerifySession prüft Signatur und Ablaufdatum und gibt Subject und
// Ablaufzeitpunkt zurück.
func (s *Signer) VerifySession(cookieValue string) (subject string, expiry time.Time, ok bool) {
encoded, signature, found := strings.Cut(cookieValue, ".")
if !found {
return "", false
return "", time.Time{}, false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
return "", time.Time{}, false
}
payload := string(payloadBytes)
// Reihenfolge ist wichtig: erst die Signatur prüfen, dann den Inhalt auswerten.
// Alles vor dieser Zeile stammt aus einer nicht vertrauenswürdigen Quelle.
if subtle.ConstantTimeCompare([]byte(signature), []byte(s.sign(payload))) != 1 {
return "", false
return "", time.Time{}, false
}
subject, expiryStr, found := strings.Cut(payload, "|")
if !found {
return "", false
return "", time.Time{}, false
}
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", false
unix, err := strconv.ParseInt(expiryStr, 10, 64)
if err != nil {
return "", time.Time{}, false
}
expiry = time.Unix(unix, 0)
now := time.Now()
if now.After(expiry) {
return "", time.Time{}, false
}
return subject, true
// Eine Sitzung, die länger gilt, als für ihre Art erlaubt ist, wird
// abgelehnt. Das erwischt Plattform-Admin-Cookies, die noch mit den
// früheren 30 Tagen ausgestellt wurden — die enden damit sofort, statt
// noch wochenlang zu gelten. (Eine Minute Spielraum für Uhren, die nicht
// ganz gleich gehen.)
if expiry.Sub(now) > SessionTTLFor(subject)+time.Minute {
return "", time.Time{}, false
}
return subject, expiry, true
}
// Fingerprint leitet aus einem Wert einen kurzen, nicht umkehrbaren

View file

@ -1,11 +1,11 @@
// Package dubletten erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt
// Package duplicates erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt
// (AUSBAUSTUFEN.md 3.3).
//
// Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt
// zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche
// (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben —
// zwei getrennte würden mit der Zeit unterschiedlich urteilen.
package dubletten
package duplicates
// Dubletten über Ähnlichkeit erkennen.
//
@ -41,8 +41,8 @@ import (
)
const (
// Schwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem
// ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung).
// Threshold ist der Mindestanteil gemeinsamer Titelwörter, ab dem
// ein vorhandener Eintrag als Kandidat gilt (siehe search.Coverage).
//
// 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf"
// und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den
@ -50,18 +50,18 @@ const (
// „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen
// zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer
// mehr.
Schwelle = 0.5
Threshold = 0.5
// suchtiefe: So viele Suchtreffer werden genauer verglichen.
// searchDepth: So viele Suchtreffer werden genauer verglichen.
// Die Suche findet Kandidaten, die Überdeckung entscheidet.
suchtiefe = 10
searchDepth = 10
// maxKandidaten: So viele Kandidaten gehen höchstens zurück.
maxKandidaten = 5
// maxCandidates: So viele Kandidaten gehen höchstens zurück.
maxCandidates = 5
)
// Kandidat ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt.
type Kandidat struct {
// Candidate ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt.
type Candidate struct {
Slug string
Title string
Overlap int // Übereinstimmung in Prozent
@ -71,7 +71,7 @@ type Kandidat struct {
MatchedWords []string
}
// Pruefe sucht vorhandene Einträge, die einem neu anzulegenden ähneln. Ist
// Check sucht vorhandene Einträge, die einem neu anzulegenden ähneln. Ist
// slug schon vergeben, wird aktualisiert statt angelegt — dann gibt es nichts
// zu prüfen, und das Ergebnis ist leer. Ein leerer slug (das Formular, bevor
// jemand einen Bezeichner eingetragen hat) überspringt diese Prüfung.
@ -80,11 +80,11 @@ type Kandidat struct {
// Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen
// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht. Ein
// Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten
// (siehe search.UeberdeckungMitSuchbegriffen).
func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project,
slug, titel, inhalt string) ([]Kandidat, error) {
// (siehe search.CoverageWithSearchTerms).
func Check(ctx context.Context, ws *workspace.Workspace, project store.Project,
slug, title, content string) ([]Candidate, error) {
if strings.TrimSpace(titel) == "" {
if strings.TrimSpace(title) == "" {
return nil, nil
}
if slug != "" {
@ -102,10 +102,10 @@ func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project,
return nil, err
}
neuerName := titel + " " + entry.AuchGesuchtAls(inhalt)
newName := title + " " + entry.AlsoSearchedAs(content)
var kandidaten []Kandidat
for _, t := range idx.Search(neuerName, suchtiefe) {
var candidates []Candidate
for _, t := range idx.Search(newName, searchDepth) {
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt
@ -115,51 +115,51 @@ func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project,
}
// Ein gemeinsames Präfix wie „Munin – " sagt nichts über das Thema,
// siehe ohneGemeinsamesPraefix.
themaNeu, themaVorhanden := ohneGemeinsamesPraefix(titel, e.Title)
// siehe withoutCommonPrefix.
topicNew, topicExisting := withoutCommonPrefix(title, e.Title)
// In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist
// vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur
// Hälfte. Beides ist ein Hinweis auf dasselbe Thema.
anteil, gemeinsam := search.UeberdeckungMitSuchbegriffen(
themaNeu, themaVorhanden, entry.AuchGesuchtAls(e.Content))
rueckwaerts, gemeinsamRueckwaerts := search.UeberdeckungMitSuchbegriffen(
themaVorhanden, themaNeu, entry.AuchGesuchtAls(inhalt))
if rueckwaerts > anteil {
anteil, gemeinsam = rueckwaerts, gemeinsamRueckwaerts
share, shared := search.CoverageWithSearchTerms(
topicNew, topicExisting, entry.AlsoSearchedAs(e.Content))
backwards, sharedBackwards := search.CoverageWithSearchTerms(
topicExisting, topicNew, entry.AlsoSearchedAs(content))
if backwards > share {
share, shared = backwards, sharedBackwards
}
if anteil < Schwelle {
if share < Threshold {
continue
}
kandidaten = append(kandidaten, Kandidat{
candidates = append(candidates, Candidate{
Slug: e.Slug,
Title: e.Title,
Overlap: int(math.Round(anteil * 100)),
MatchedWords: gemeinsam,
Overlap: int(math.Round(share * 100)),
MatchedWords: shared,
})
}
// Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge
// der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall.
sort.SliceStable(kandidaten, func(i, j int) bool {
return kandidaten[i].Overlap > kandidaten[j].Overlap
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].Overlap > candidates[j].Overlap
})
if len(kandidaten) > maxKandidaten {
kandidaten = kandidaten[:maxKandidaten]
if len(candidates) > maxCandidates {
candidates = candidates[:maxCandidates]
}
return kandidaten, nil
return candidates, nil
}
// titelTrenner trennt ein Präfix vom eigentlichen Thema eines Titels:
// titleSeparator trennt ein Präfix vom eigentlichen Thema eines Titels:
// „Munin – Architektur", „Munin — Architektur", „Munin - Architektur",
// „Munin: Architektur", „Munin | Architektur".
//
// Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne
// Leerzeichen ist er Teil eines Worts — „Deployment-Ablauf" hat kein Präfix.
var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`)
var titleSeparator = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`)
// ohneGemeinsamesPraefix entfernt ein Präfix, das beide Titel gemeinsam
// withoutCommonPrefix entfernt ein Präfix, das beide Titel gemeinsam
// haben, und gibt die beiden Themen zurück.
//
// # Warum?
@ -175,28 +175,28 @@ var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`)
// verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer
// eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas
// unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls.
func ohneGemeinsamesPraefix(neu, vorhanden string) (string, string) {
praefixNeu, themaNeu, okNeu := titelTeilen(neu)
praefixVorhanden, themaVorhanden, okVorhanden := titelTeilen(vorhanden)
if !okNeu || !okVorhanden || praefixNeu != praefixVorhanden {
return neu, vorhanden
func withoutCommonPrefix(newer, existing string) (string, string) {
prefixNew, topicNew, okNew := titleParts(newer)
prefixExisting, topicExisting, okExisting := titleParts(existing)
if !okNew || !okExisting || prefixNew != prefixExisting {
return newer, existing
}
return themaNeu, themaVorhanden
return topicNew, topicExisting
}
// titelTeilen trennt einen Titel am ersten Trenner in Präfix und Thema. Das
// titleParts trennt einen Titel am ersten Trenner in Präfix und Thema. Das
// Präfix kommt vereinheitlicht zurück (klein, einfache Leerzeichen), damit
// „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen
// Trenner gibt oder eine Seite leer ist.
func titelTeilen(titel string) (praefix, thema string, ok bool) {
pos := titelTrenner.FindStringIndex(titel)
func titleParts(title string) (prefix, topic string, ok bool) {
pos := titleSeparator.FindStringIndex(title)
if pos == nil {
return "", titel, false
return "", title, false
}
praefix = strings.Join(strings.Fields(strings.ToLower(titel[:pos[0]])), " ")
thema = strings.TrimSpace(titel[pos[1]:])
if praefix == "" || thema == "" {
return "", titel, false
prefix = strings.Join(strings.Fields(strings.ToLower(title[:pos[0]])), " ")
topic = strings.TrimSpace(title[pos[1]:])
if prefix == "" || topic == "" {
return "", title, false
}
return praefix, thema, true
return prefix, topic, true
}

View file

@ -1,14 +1,14 @@
package dubletten
package duplicates
import "testing"
// TestOhneGemeinsamesPraefix prüft, wann das Präfix eines Titels aus dem
// TestWithoutCommonPrefix prüft, wann das Präfix eines Titels aus dem
// Dublettenvergleich herausfällt — nur wenn beide Titel dasselbe haben.
func TestOhneGemeinsamesPraefix(t *testing.T) {
faelle := []struct {
name string
neu, vorhanden string
themaNeu, themaVorhand string
func TestWithoutCommonPrefix(t *testing.T) {
cases := []struct {
name string
newer, existing string
topicNew, topicExisting string
}{
{"gleiches Präfix mit Halbgeviertstrich",
"Munin – Architektur", "Munin – Überblick", "Architektur", "Überblick"},
@ -36,12 +36,12 @@ func TestOhneGemeinsamesPraefix(t *testing.T) {
"Munin – ", "Munin – Überblick", "Munin – ", "Munin – Überblick"},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
neu, vorhanden := ohneGemeinsamesPraefix(f.neu, f.vorhanden)
if neu != f.themaNeu || vorhanden != f.themaVorhand {
newer, existing := withoutCommonPrefix(f.newer, f.existing)
if newer != f.topicNew || existing != f.topicExisting {
t.Errorf("ohneGemeinsamesPraefix(%q, %q) = (%q, %q), erwartet (%q, %q)",
f.neu, f.vorhanden, neu, vorhanden, f.themaNeu, f.themaVorhand)
f.newer, f.existing, newer, existing, f.topicNew, f.topicExisting)
}
})
}

View file

@ -33,14 +33,14 @@ const maxDiffCells = 4_000_000
// verschobener, aber unveränderter Absatz wird dabei nicht zwingend als
// "gleich" erkannt, wenn sich in seiner Umgebung etwas geändert hat — für die
// Frage "was hat sich bei diesem Schreibvorgang geändert" reicht das.
func DiffLines(alt, neu string) []DiffLine {
altZeilen := splitLines(alt)
neuZeilen := splitLines(neu)
func DiffLines(old, newer string) []DiffLine {
oldLines := splitLines(old)
newLines := splitLines(newer)
if len(altZeilen)*len(neuZeilen) > maxDiffCells {
return grobDiff(altZeilen, neuZeilen)
if len(oldLines)*len(newLines) > maxDiffCells {
return coarseDiff(oldLines, newLines)
}
return lcsDiff(altZeilen, neuZeilen)
return lcsDiff(oldLines, newLines)
}
func splitLines(s string) []string {
@ -50,15 +50,15 @@ func splitLines(s string) []string {
return strings.Split(s, "\n")
}
// grobDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine
// coarseDiff dient als Rückfallebene für außergewöhnlich lange Einträge: keine
// Zeilenzuordnung, nur der komplette alte Text als entfernt und der
// komplette neue als hinzugefügt.
func grobDiff(altZeilen, neuZeilen []string) []DiffLine {
lines := make([]DiffLine, 0, len(altZeilen)+len(neuZeilen))
for _, z := range altZeilen {
func coarseDiff(oldLines, newLines []string) []DiffLine {
lines := make([]DiffLine, 0, len(oldLines)+len(newLines))
for _, z := range oldLines {
lines = append(lines, DiffLine{Op: DiffRemove, Text: z})
}
for _, z := range neuZeilen {
for _, z := range newLines {
lines = append(lines, DiffLine{Op: DiffAdd, Text: z})
}
return lines

View file

@ -30,13 +30,13 @@ func TestDiffLinesIdentical(t *testing.T) {
}
func TestDiffLinesChangeInMiddle(t *testing.T) {
alt := "Zeile 1\nZeile 2\nZeile 3"
neu := "Zeile 1\nGeändert\nZeile 3"
old := "Zeile 1\nZeile 2\nZeile 3"
newer := "Zeile 1\nGeändert\nZeile 3"
got := formatDiff(DiffLines(alt, neu))
erwartet := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n"
if got != erwartet {
t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, erwartet)
got := formatDiff(DiffLines(old, newer))
want := " Zeile 1\n- Zeile 2\n+ Geändert\n Zeile 3\n"
if got != want {
t.Errorf("DiffLines =\n%s\nerwartet:\n%s", got, want)
}
}
@ -57,14 +57,14 @@ func TestDiffLinesEmptyOldContent(t *testing.T) {
func TestDiffLinesFallsBackForHugeInput(t *testing.T) {
// Erzwingt den grobDiff-Pfad ohne wirklich riesige Strings zu bauen: die
// Grenze fällt auf Zeilenanzahl, nicht Textlänge.
altGross := make([]byte, 0, 2200*2)
neuGross := make([]byte, 0, 2200*2)
oldUpper := make([]byte, 0, 2200*2)
newUpper := make([]byte, 0, 2200*2)
for i := 0; i < 2200; i++ {
altGross = append(altGross, 'a', '\n')
neuGross = append(neuGross, 'b', '\n')
oldUpper = append(oldUpper, 'a', '\n')
newUpper = append(newUpper, 'b', '\n')
}
lines := DiffLines(string(altGross), string(neuGross))
lines := DiffLines(string(oldUpper), string(newUpper))
for _, l := range lines {
if l.Op == DiffEqual {
t.Fatal("grobDiff sollte keine DiffEqual-Zeilen liefern")

View file

@ -27,18 +27,18 @@ import (
// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
// Escaping einsetzt.
func RenderHTML(quelle string, linkBase string) (string, error) {
func RenderHTML(source string, linkBase string) (string, error) {
// Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu
// spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt.
//
// (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der
// (Der Parameter heißt "source" statt "markdown", weil "markdown" jetzt der
// Name des importierten Pakets ist — sonst wäre das Paket in dieser
// Funktion verdeckt.)
vorbereitet := store.ReplaceLinks(quelle, func(slug string) string {
prepared := store.ReplaceLinks(source, func(slug string) string {
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
})
return markdown.Render(vorbereitet)
return markdown.Render(prepared)
}
// RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren
@ -58,34 +58,34 @@ const snippetRadius = 120
func Snippet(content, query string) string {
text := RenderPlain(content)
nadel := trefferwort(text, query)
if nadel == "" {
needle := hitWord(text, query)
if needle == "" {
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über
// eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
// Vorschau.
return kuerzen(text, snippetRadius+snippetRadius)
return truncate(text, snippetRadius+snippetRadius)
}
idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel))
idx := strings.Index(strings.ToLower(text), strings.ToLower(needle))
start := max(idx-snippetRadius, 0)
ende := min(idx+len(nadel)+snippetRadius, len(text))
end := min(idx+len(needle)+snippetRadius, len(text))
ausschnitt := strings.TrimSpace(text[start:ende])
excerpt := strings.TrimSpace(text[start:end])
if start > 0 {
ausschnitt = "…" + ausschnitt
excerpt = "…" + excerpt
}
if ende < len(text) {
ausschnitt += "…"
if end < len(text) {
excerpt += "…"
}
return ausschnitt
return excerpt
}
// minTrefferwort ist die Mindestlänge eines einzelnen Worts der Anfrage, an
// minHitWord ist die Mindestlänge eines einzelnen Worts der Anfrage, an
// dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen
// in fast jedem Text und sagen nichts über den Treffer.
const minTrefferwort = 4
const minHitWord = 4
// trefferwort bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück
// hitWord bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück
// der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor.
//
// # Warum nicht einfach die ganze Anfrage?
@ -99,42 +99,42 @@ const minTrefferwort = 4
// genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die
// Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel"
// verrät mehr über die Fundstelle als „Dienst".
func trefferwort(text, query string) string {
func hitWord(text, query string) string {
query = strings.TrimSpace(query)
if query == "" {
return ""
}
klein := strings.ToLower(text)
if strings.Contains(klein, strings.ToLower(query)) {
lower := strings.ToLower(text)
if strings.Contains(lower, strings.ToLower(query)) {
return query
}
// FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert —
// hier an allem, was kein Buchstabe und keine Ziffer ist.
woerter := strings.FieldsFunc(query, func(r rune) bool {
words := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
// Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge
// der Anfrage stehen — so ist das Ergebnis immer dasselbe.
sort.SliceStable(woerter, func(i, j int) bool {
return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j])
sort.SliceStable(words, func(i, j int) bool {
return utf8.RuneCountInString(words[i]) > utf8.RuneCountInString(words[j])
})
for _, w := range woerter {
if utf8.RuneCountInString(w) < minTrefferwort {
for _, w := range words {
if utf8.RuneCountInString(w) < minHitWord {
break // sortiert: alle folgenden sind noch kürzer
}
if strings.Contains(klein, strings.ToLower(w)) {
if strings.Contains(lower, strings.ToLower(w)) {
return w
}
}
return ""
}
// kuerzen schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string {
// truncate schneidet einen Text auf höchstens n Zeichen.
func truncate(text string, n int) string {
text = strings.TrimSpace(text)
if len(text) <= n {
return text
@ -148,34 +148,34 @@ func kuerzen(text string, n int) string {
// Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen
// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen.
func HighlightSnippet(content, query string) string {
ausschnitt := Snippet(content, query)
sicher := html.EscapeString(ausschnitt)
excerpt := Snippet(content, query)
safe := html.EscapeString(excerpt)
if query == "" {
return sicher
return safe
}
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
// ebenfalls escapten Stück, damit die Positionen zusammenpassen.
treffer := trefferwort(ausschnitt, query)
if treffer == "" {
return sicher
hits := hitWord(excerpt, query)
if hits == "" {
return safe
}
nadel := html.EscapeString(treffer)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
needle := html.EscapeString(hits)
idx := strings.Index(strings.ToLower(safe), strings.ToLower(needle))
if idx == -1 {
return sicher
return safe
}
return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):]
return safe[:idx] + "<mark>" + safe[idx:idx+len(needle)] + "</mark>" + safe[idx+len(needle):]
}
// auchGesuchtAlsPraefix leitet die Zeile ein, in der ein Eintrag weitere
// alsoSearchedAsPrefix leitet die Zeile ein, in der ein Eintrag weitere
// Suchbegriffe nennt (siehe instructions in internal/mcpserver).
const auchGesuchtAlsPraefix = "auch gesucht als"
const alsoSearchedAsPrefix = "auch gesucht als"
// AuchGesuchtAls gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines
// AlsoSearchedAs gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines
// Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "".
//
// Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch
@ -187,33 +187,33 @@ const auchGesuchtAlsPraefix = "auch gesucht als"
//
// Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet
// darum, die Zeile ans Ende zu stellen.
func AuchGesuchtAls(content string) string {
ergebnis := ""
for _, zeile := range strings.Split(content, "\n") {
func AlsoSearchedAs(content string) string {
result := ""
for _, line := range strings.Split(content, "\n") {
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ")
if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) {
line = strings.TrimLeft(strings.TrimSpace(line), "-*_> ")
if !strings.HasPrefix(strings.ToLower(line), alsoSearchedAsPrefix) {
continue
}
rest := zeile[len(auchGesuchtAlsPraefix):]
rest := line[len(alsoSearchedAsPrefix):]
rest = strings.TrimLeft(rest, ":*_ ")
ergebnis = strings.TrimSpace(rest)
result = strings.TrimSpace(rest)
}
return ergebnis
return result
}
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string {
for _, zeile := range strings.Split(content, "\n") {
zeile = strings.TrimSpace(zeile)
if zeile == "" {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
zeile = strings.TrimLeft(zeile, "#")
zeile = strings.TrimSpace(zeile)
if zeile != "" {
return kuerzen(zeile, 120)
line = strings.TrimLeft(line, "#")
line = strings.TrimSpace(line)
if line != "" {
return truncate(line, 120)
}
}
return "Ohne Titel"

View file

@ -12,19 +12,19 @@ import (
// einschleust, hängt damit allein daran, dass goldmark kein Roh-HTML durchreicht.
// Wird diese Einstellung je gelockert, schlägt dieser Test an.
func TestRenderHTMLEscapesRawHTML(t *testing.T) {
boesartig := []string{
malicious := []string{
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
"<iframe src=\"https://fremd.example\"></iframe>",
}
for _, eingabe := range boesartig {
html, err := RenderHTML(eingabe, "/p/test/e/")
for _, input := range malicious {
html, err := RenderHTML(input, "/p/test/e/")
if err != nil {
t.Fatalf("RenderHTML(%q): %v", eingabe, err)
t.Fatalf("RenderHTML(%q): %v", input, err)
}
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "<iframe") {
t.Errorf("Roh-HTML kam durch: %q → %q", eingabe, html)
t.Errorf("Roh-HTML kam durch: %q → %q", input, html)
}
}
}
@ -48,9 +48,9 @@ func TestRenderHTMLKeepsMarkdown(t *testing.T) {
t.Fatal(err)
}
for _, erwartet := range []string{"<h1", "<ul>", "<li>", "<code>"} {
if !strings.Contains(html, erwartet) {
t.Errorf("%s fehlt im Ergebnis: %s", erwartet, html)
for _, want := range []string{"<h1", "<ul>", "<li>", "<code>"} {
if !strings.Contains(html, want) {
t.Errorf("%s fehlt im Ergebnis: %s", want, html)
}
}
}
@ -97,10 +97,10 @@ func TestSnippetWithoutMatch(t *testing.T) {
}
}
// TestSnippetBeiGanzerFrage prüft den Normalfall bei Agenten: Die Anfrage ist
// TestSnippetForWholeQuestion prüft den Normalfall bei Agenten: Die Anfrage ist
// eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an
// der Fundstelle ansetzen, nicht am Textanfang.
func TestSnippetBeiGanzerFrage(t *testing.T) {
func TestSnippetForWholeQuestion(t *testing.T) {
text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "wie läuft der Zertifikatswechsel")
@ -110,9 +110,9 @@ func TestSnippetBeiGanzerFrage(t *testing.T) {
}
}
// TestSnippetLaengstesWortZuerst prüft die Wahl zwischen mehreren Wörtern:
// TestSnippetLongestWordFirst prüft die Wahl zwischen mehreren Wörtern:
// Das längere, aussagekräftigere gewinnt, auch wenn das kürzere früher steht.
func TestSnippetLaengstesWortZuerst(t *testing.T) {
func TestSnippetLongestWordFirst(t *testing.T) {
text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier."
got := Snippet(text, "dienst kubernetes")
@ -122,10 +122,10 @@ func TestSnippetLaengstesWortZuerst(t *testing.T) {
}
}
func TestTrefferwort(t *testing.T) {
func TestHitWord(t *testing.T) {
text := "Wir deployen über GitHub Actions."
faelle := map[string]string{
cases := map[string]string{
"GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor
"wie deployen wir": "deployen",
"wie wir": "", // nur kurze Wörter
@ -134,16 +134,16 @@ func TestTrefferwort(t *testing.T) {
"actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage
}
for anfrage, erwartet := range faelle {
if got := trefferwort(text, anfrage); got != erwartet {
t.Errorf("trefferwort(%q) = %q, erwartet %q", anfrage, got, erwartet)
for query, want := range cases {
if got := hitWord(text, query); got != want {
t.Errorf("trefferwort(%q) = %q, erwartet %q", query, got, want)
}
}
}
// TestHighlightSnippetEinzelwort prüft, dass bei einer ganzen Frage das Wort
// TestHighlightSnippetSingleWord prüft, dass bei einer ganzen Frage das Wort
// hervorgehoben wird, an dem sich der Ausschnitt ausrichtet.
func TestHighlightSnippetEinzelwort(t *testing.T) {
func TestHighlightSnippetSingleWord(t *testing.T) {
got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir")
if !strings.Contains(got, "<mark>deployen</mark>") {
@ -200,16 +200,16 @@ func TestTitleFromContent(t *testing.T) {
" \n \n": "Ohne Titel",
}
for eingabe, erwartet := range cases {
if got := TitleFromContent(eingabe); got != erwartet {
t.Errorf("TitleFromContent(%q) = %q, erwartet %q", eingabe, got, erwartet)
for input, want := range cases {
if got := TitleFromContent(input); got != want {
t.Errorf("TitleFromContent(%q) = %q, erwartet %q", input, got, want)
}
}
}
func TestTitleFromContentIsShortened(t *testing.T) {
lang := strings.Repeat("Wort ", 100)
got := TitleFromContent(lang)
long := strings.Repeat("Wort ", 100)
got := TitleFromContent(long)
if len(got) > 130 {
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got))
@ -218,8 +218,8 @@ func TestTitleFromContentIsShortened(t *testing.T) {
// ── Auch gesucht als ─────────────────────────────────────────────────────────
func TestAuchGesuchtAls(t *testing.T) {
faelle := map[string]string{
func TestAlsoSearchedAs(t *testing.T) {
cases := map[string]string{
"Text.\n\nAuch gesucht als: release, rollout": "release, rollout",
"Text.\n*Auch gesucht als:* release": "release",
"- **Auch gesucht als:** release, auslieferung": "release, auslieferung",
@ -229,9 +229,9 @@ func TestAuchGesuchtAls(t *testing.T) {
"Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang
}
for inhalt, erwartet := range faelle {
if got := AuchGesuchtAls(inhalt); got != erwartet {
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", inhalt, got, erwartet)
for content, want := range cases {
if got := AlsoSearchedAs(content); got != want {
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", content, got, want)
}
}
}

View file

@ -67,38 +67,38 @@ type Exporter struct {
// letzter ist der zuletzt abgeschlossene Lauf über alle Workspaces —
// für die Systemseite. Der Mutex schützt ihn, weil der tägliche Lauf in
// einer eigenen Goroutine schreibt, während die Seite liest.
mu sync.Mutex
letzter Lauf
mu sync.Mutex
last Run
}
// Intervall bestimmt, wie oft der Markdown-Export automatisch läuft (siehe
// Interval bestimmt, wie oft der Markdown-Export automatisch läuft (siehe
// main.go). Die Systemseite meldet einen Export als überfällig, wenn der
// letzte Lauf deutlich länger zurückliegt.
const Intervall = 24 * time.Hour
const Interval = 24 * time.Hour
// Lauf beschreibt einen Export über alle Workspaces.
type Lauf struct {
Ende time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig
Dauer time.Duration
Stats Stats
Fehler string // leer = vollständig
// Run beschreibt einen Export über alle Workspaces.
type Run struct {
End time.Time // Nullzeitpunkt = seit dem Start noch keiner fertig
Duration time.Duration
Stats Stats
Err string // leer = vollständig
}
// MerkeLauf hält das Ergebnis eines Laufs über alle Workspaces fest. Den
// RecordRun hält das Ergebnis eines Laufs über alle Workspaces fest. Den
// Knopf "Exportieren" für einen einzelnen Workspace merkt sich der Exporter
// nicht: Er sagt nichts darüber, ob die übrigen gesichert sind.
func (e *Exporter) MerkeLauf(l Lauf) {
func (e *Exporter) RecordRun(l Run) {
e.mu.Lock()
defer e.mu.Unlock()
e.letzter = l
e.last = l
}
// LetzterLauf liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner
// LastRun liefert den zuletzt gemerkten Lauf (Nullwert, wenn noch keiner
// fertig ist).
func (e *Exporter) LetzterLauf() Lauf {
func (e *Exporter) LastRun() Run {
e.mu.Lock()
defer e.mu.Unlock()
return e.letzter
return e.last
}
// New erstellt einen Exporter, der unter root schreibt.
@ -123,42 +123,42 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
var stats Stats
// Die Workspace-ID wird Teil des Pfads — dieselbe Vorsicht wie beim
// Projekt-Bezeichner (siehe projektDateien). IDs aus store.NewID bestehen
// Projekt-Bezeichner (siehe projectFiles). IDs aus store.NewID bestehen
// nur aus Kleinbuchstaben, Ziffern und Bindestrich und bestehen diese
// Prüfung.
if !store.ValidSlug(project.WorkspaceID) {
return stats, fmt.Errorf("ungültige Workspace-ID %q", project.WorkspaceID)
}
dateien, err := projektDateien(ctx, s, project)
files, err := projectFiles(ctx, s, project)
if err != nil {
return stats, err
}
ziel := filepath.Join(e.root, project.WorkspaceID, project.Slug)
if err := os.MkdirAll(ziel, 0750); err != nil {
target := filepath.Join(e.root, project.WorkspaceID, project.Slug)
if err := os.MkdirAll(target, 0750); err != nil {
return stats, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
}
// Namen merken, um danach zu wissen, was übrig bleibt.
erwartet := make(map[string]bool, len(dateien))
for _, d := range dateien {
want := make(map[string]bool, len(files))
for _, d := range files {
// Im Ordner des Projekts zählt nur der Dateiname, ohne den
// Projektordner davor.
name := filepath.Base(d.Pfad)
erwartet[name] = true
name := filepath.Base(d.Path)
want[name] = true
if err := schreibeDatei(filepath.Join(ziel, name), d.Inhalt); err != nil {
if err := writeFile(filepath.Join(target, name), d.Content); err != nil {
return stats, err
}
stats.Written++
}
entfernt, err := raeumeAuf(ziel, erwartet)
removed, err := cleanup(target, want)
if err != nil {
return stats, err
}
stats.Removed = entfernt
stats.Removed = removed
return stats, nil
}
@ -166,41 +166,41 @@ func (e *Exporter) ExportProject(ctx context.Context, s *store.Store, project st
// ExportWorkspace exportiert alle Projekte eines Workspace. s muss die
// Workspace-Datei von ws sein.
func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store.Workspace) (Stats, error) {
var gesamt Stats
var total Stats
if !store.ValidSlug(ws.ID) {
return gesamt, fmt.Errorf("ungültige Workspace-ID %q", ws.ID)
return total, fmt.Errorf("ungültige Workspace-ID %q", ws.ID)
}
ordner := filepath.Join(e.root, ws.ID)
if err := os.MkdirAll(ordner, 0750); err != nil {
return gesamt, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
dir := filepath.Join(e.root, ws.ID)
if err := os.MkdirAll(dir, 0750); err != nil {
return total, fmt.Errorf("Exportordner konnte nicht angelegt werden: %w", err)
}
// Ohne diese Datei wäre der Ordner nur eine ID — niemand wüsste beim
// Wiederherstellen, welche Abteilung darin steckt.
if err := schreibeDatei(filepath.Join(ordner, workspaceDateiname), rendereWorkspace(ws)); err != nil {
return gesamt, err
if err := writeFile(filepath.Join(dir, workspaceFilename), renderWorkspace(ws)); err != nil {
return total, err
}
gesamt.Written++
total.Written++
projekte, err := s.ListProjects(ctx, ws.ID)
projects, err := s.ListProjects(ctx, ws.ID)
if err != nil {
return gesamt, err
return total, err
}
for _, p := range projekte {
for _, p := range projects {
stats, err := e.ExportProject(ctx, s, p)
if err != nil {
return gesamt, fmt.Errorf("Projekt %q: %w", p.Slug, err)
return total, fmt.Errorf("Projekt %q: %w", p.Slug, err)
}
gesamt.Written += stats.Written
gesamt.Removed += stats.Removed
total.Written += stats.Written
total.Removed += stats.Removed
}
return gesamt, nil
return total, nil
}
// projektDateien stellt alle Dateien eines Projekts zusammen, ohne etwas zu
// projectFiles stellt alle Dateien eines Projekts zusammen, ohne etwas zu
// schreiben: je Eintrag eine Datei und die Übersicht _index.md, alle unter
// "<projekt-slug>/".
//
@ -208,7 +208,7 @@ func (e *Exporter) ExportWorkspace(ctx context.Context, s *store.Store, ws store
// hierauf auf. So legt nur eine Stelle fest, wie ein Export aussieht — und
// ein heruntergeladenes ZIP passt genauso in den Import wie ein Ordner aus
// EXPORT_DIR.
func projektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
func projectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, error) {
// Der Projekt-Bezeichner ist beim Anlegen geprüft worden (siehe
// store.ValidSlug) und kann keine Pfadanteile enthalten. Die Prüfung hier
// noch einmal, weil ein Wert aus der Datenbank grundsätzlich nichts
@ -218,51 +218,51 @@ func projektDateien(ctx context.Context, s *store.Store, project store.Project)
return nil, fmt.Errorf("ungültiger Projekt-Bezeichner %q", project.Slug)
}
eintraege, err := s.AllEntries(ctx, project.ID)
entries, err := s.AllEntries(ctx, project.ID)
if err != nil {
return nil, err
}
dateien := make([]Datei, 0, len(eintraege)+1)
for _, eintrag := range eintraege {
if !store.ValidSlug(eintrag.Slug) {
return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", eintrag.Slug, project.Slug)
files := make([]File, 0, len(entries)+1)
for _, entry := range entries {
if !store.ValidSlug(entry.Slug) {
return nil, fmt.Errorf("ungültiger Bezeichner %q in Projekt %q", entry.Slug, project.Slug)
}
dateien = append(dateien, Datei{
Pfad: project.Slug + "/" + eintrag.Slug + ".md",
Inhalt: rendere(eintrag),
Geaendert: eintrag.UpdatedAt,
files = append(files, File{
Path: project.Slug + "/" + entry.Slug + ".md",
Content: render(entry),
Modified: entry.UpdatedAt,
})
}
// Eine Übersicht dazulegen, damit der Ordner auch für Menschen brauchbar ist.
dateien = append(dateien, Datei{
Pfad: project.Slug + "/" + indexDateiname,
Inhalt: rendereIndex(project, eintraege),
files = append(files, File{
Path: project.Slug + "/" + indexFilename,
Content: renderIndex(project, entries),
})
return dateien, nil
return files, nil
}
// indexDateiname ist die Übersicht, die je Projekt mit erzeugt wird.
const indexDateiname = "_index.md"
// indexFilename ist die Übersicht, die je Projekt mit erzeugt wird.
const indexFilename = "_index.md"
// workspaceDateiname nennt den Workspace, zu dem ein ID-Ordner gehört.
const workspaceDateiname = "_workspace.md"
// workspaceFilename nennt den Workspace, zu dem ein ID-Ordner gehört.
const workspaceFilename = "_workspace.md"
// rendereWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne
// renderWorkspace baut den Inhalt von _workspace.md. Wie beim Index ohne
// Zeitstempel des Laufs, damit ein unveränderter Workspace keinen Diff erzeugt.
func rendereWorkspace(ws store.Workspace) string {
func renderWorkspace(ws store.Workspace) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "id", ws.ID)
schreibeFeld(&b, "name", ws.Name)
writeField(&b, "id", ws.ID)
writeField(&b, "name", ws.Name)
b.WriteString("---\n\n")
b.WriteString("# Workspace " + ws.Name + "\n")
return b.String()
}
// schreibeDatei legt eine Datei an — nur, wenn sich der Inhalt geändert hat.
// writeFile legt eine Datei an — nur, wenn sich der Inhalt geändert hat.
//
// # Warum der Vergleich?
//
@ -270,69 +270,69 @@ func rendereWorkspace(ws store.Workspace) string {
// geschrieben, änderte sich zwar der Inhalt nicht, wohl aber die Änderungszeit —
// und Werkzeuge, die darauf schauen, meldeten ständig Bewegung, wo keine ist.
// Git selbst stört das nicht, aber Sicherungsprogramme und Dateiüberwachungen schon.
func schreibeDatei(pfad, inhalt string) error {
if vorhanden, err := os.ReadFile(pfad); err == nil && string(vorhanden) == inhalt {
func writeFile(filePath, content string) error {
if existing, err := os.ReadFile(filePath); err == nil && string(existing) == content {
return nil
}
if err := os.WriteFile(pfad, []byte(inhalt), 0640); err != nil {
return fmt.Errorf("%s konnte nicht geschrieben werden: %w", pfad, err)
if err := os.WriteFile(filePath, []byte(content), 0640); err != nil {
return fmt.Errorf("%s konnte nicht geschrieben werden: %w", filePath, err)
}
return nil
}
// raeumeAuf entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören.
// cleanup entfernt Markdown-Dateien, die zu keinem Eintrag mehr gehören.
//
// Es werden ausschließlich .md-Dateien angefasst und keine Unterordner — falls
// jemand eigene Notizen daneben legt, bleiben sie unberührt.
func raeumeAuf(ordner string, erwartet map[string]bool) (int, error) {
eintraege, err := os.ReadDir(ordner)
func cleanup(dir string, want map[string]bool) (int, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, fmt.Errorf("Exportordner konnte nicht gelesen werden: %w", err)
}
var entfernt int
for _, e := range eintraege {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || erwartet[e.Name()] {
var removed int
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || want[e.Name()] {
continue
}
if err := os.Remove(filepath.Join(ordner, e.Name())); err != nil {
return entfernt, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err)
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
return removed, fmt.Errorf("%s konnte nicht entfernt werden: %w", e.Name(), err)
}
entfernt++
removed++
}
return entfernt, nil
return removed, nil
}
// rendere baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown.
// render baut den Dateiinhalt eines Eintrags: YAML-Kopf plus Markdown.
//
// Der Kopf enthält alle Felder, damit sich der Bestand daraus auch wieder
// einlesen ließe — nicht nur Titel und Text.
func rendere(e store.Entry) string {
func render(e store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "slug", e.Slug)
schreibeFeld(&b, "title", e.Title)
writeField(&b, "slug", e.Slug)
writeField(&b, "title", e.Title)
if len(e.Tags) > 0 {
b.WriteString("tags: [")
for i, t := range e.Tags {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(yamlWert(t))
b.WriteString(yamlValue(t))
}
b.WriteString("]\n")
}
schreibeFeld(&b, "source", e.Source)
writeField(&b, "source", e.Source)
if e.Author != "" {
schreibeFeld(&b, "author", e.Author)
writeField(&b, "author", e.Author)
}
b.WriteString("curated: " + strconv.FormatBool(e.Curated) + "\n")
b.WriteString("version: " + strconv.Itoa(e.Version) + "\n")
schreibeFeld(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339))
schreibeFeld(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339))
writeField(&b, "created_at", e.CreatedAt.UTC().Format(time.RFC3339))
writeField(&b, "updated_at", e.UpdatedAt.UTC().Format(time.RFC3339))
b.WriteString("---\n\n")
b.WriteString("# " + e.Title + "\n\n")
@ -342,17 +342,17 @@ func rendere(e store.Entry) string {
return b.String()
}
// rendereIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert.
// renderIndex baut die Übersicht eines Projekts, nach Schlagwort gruppiert.
//
// Bewusst OHNE Zeitstempel im Kopf: Er würde sich bei jedem Lauf ändern und
// damit auch dann einen Git-Diff erzeugen, wenn sich inhaltlich nichts getan
// hat. Wann exportiert wurde, sagen der Git-Commit und die Änderungszeit der
// Datei ohnehin — nur eben nur dann, wenn es etwas zu sagen gab.
func rendereIndex(project store.Project, eintraege []store.Entry) string {
func renderIndex(project store.Project, entries []store.Entry) string {
var b strings.Builder
b.WriteString("---\n")
schreibeFeld(&b, "title", project.Name+" — Übersicht")
writeField(&b, "title", project.Name+" — Übersicht")
b.WriteString("---\n\n")
b.WriteString("# " + project.Name + "\n\n")
@ -360,40 +360,40 @@ func rendereIndex(project store.Project, eintraege []store.Entry) string {
b.WriteString(project.Description + "\n\n")
}
b.WriteString(fmt.Sprintf("%d Einträge. Automatisch erzeugt — Änderungen hier wirken nicht zurück.\n\n",
len(eintraege)))
len(entries)))
// Nach erstem Schlagwort gruppieren; Einträge ohne Schlagwort ans Ende.
const ohne = "Ohne Schlagwort"
gruppen := make(map[string][]store.Entry)
for _, e := range eintraege {
kategorie := ohne
const without = "Ohne Schlagwort"
groups := make(map[string][]store.Entry)
for _, e := range entries {
category := without
if len(e.Tags) > 0 {
kategorie = e.Tags[0]
category = e.Tags[0]
}
gruppen[kategorie] = append(gruppen[kategorie], e)
groups[category] = append(groups[category], e)
}
kategorien := make([]string, 0, len(gruppen))
for k := range gruppen {
kategorien = append(kategorien, k)
categories := make([]string, 0, len(groups))
for k := range groups {
categories = append(categories, k)
}
sort.Slice(kategorien, func(i, j int) bool {
if kategorien[i] == ohne {
sort.Slice(categories, func(i, j int) bool {
if categories[i] == without {
return false
}
if kategorien[j] == ohne {
if categories[j] == without {
return true
}
return kategorien[i] < kategorien[j]
return categories[i] < categories[j]
})
for _, kategorie := range kategorien {
b.WriteString("## " + kategorie + "\n\n")
for _, category := range categories {
b.WriteString("## " + category + "\n\n")
liste := gruppen[kategorie]
sort.Slice(liste, func(i, j int) bool { return liste[i].Title < liste[j].Title })
list := groups[category]
sort.Slice(list, func(i, j int) bool { return list[i].Title < list[j].Title })
for _, e := range liste {
for _, e := range list {
b.WriteString(fmt.Sprintf("- [%s](%s.md)", e.Title, e.Slug))
if e.Curated {
b.WriteString(" ✓")
@ -409,19 +409,19 @@ func rendereIndex(project store.Project, eintraege []store.Entry) string {
return b.String()
}
// schreibeFeld hängt ein YAML-Feld an.
func schreibeFeld(b *strings.Builder, name, wert string) {
b.WriteString(name + ": " + yamlWert(wert) + "\n")
// writeField hängt ein YAML-Feld an.
func writeField(b *strings.Builder, name, value string) {
b.WriteString(name + ": " + yamlValue(value) + "\n")
}
// yamlWert bringt einen Wert in eine sichere YAML-Schreibweise.
// yamlValue bringt einen Wert in eine sichere YAML-Schreibweise.
//
// Immer in doppelte Anführungszeichen und die enthaltenen maskieren: Damit ist
// egal, ob der Wert einen Doppelpunkt, ein Rautezeichen oder einen Zeilenumbruch
// enthält. Ohne diese Vorsicht würde ein Titel wie "Fehler: Zugriff verweigert"
// die Datei unlesbar machen.
func yamlWert(s string) string {
ersetzt := strings.NewReplacer(
func yamlValue(s string) string {
replaced := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
@ -429,5 +429,5 @@ func yamlWert(s string) string {
"\t", `\t`,
).Replace(s)
return `"` + ersetzt + `"`
return `"` + replaced + `"`
}

View file

@ -10,8 +10,8 @@ import (
"munin/internal/store"
)
// aufbau legt Datenbank, Projekt und Exporter an.
func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
// setup legt Datenbank, Projekt und Exporter an.
func setup(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
@ -25,7 +25,7 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
// der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
// den Test reicht eine beliebige ID.
workspaceID := store.NewID()
projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams")
project, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams")
if err != nil {
t.Fatal(err)
}
@ -34,33 +34,33 @@ func aufbau(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
// gleich der Workspace-Ordner, damit die Tests Pfade wie
// ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen.
root := t.TempDir()
return New(root), db, projekt, filepath.Join(root, workspaceID)
return New(root), db, project, filepath.Join(root, workspaceID)
}
// lies gibt den Inhalt einer exportierten Datei zurück.
func lies(t *testing.T, teile ...string) string {
// readFile gibt den Inhalt einer exportierten Datei zurück.
func readFile(t *testing.T, parts ...string) string {
t.Helper()
inhalt, err := os.ReadFile(filepath.Join(teile...))
content, err := os.ReadFile(filepath.Join(parts...))
if err != nil {
t.Fatalf("Datei nicht lesbar: %v", err)
}
return string(inhalt)
return string(content)
}
func TestExportWritesEntries(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "deployment", Title: "Deployment-Prozess",
ProjectID: project.ID, Slug: "deployment", Title: "Deployment-Prozess",
Content: "Wir deployen über GitHub Actions.", Tags: []string{"ops", "deployment"},
Source: store.SourceAgent, Author: "claude", Curated: true,
}); err != nil {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, db, projekt)
stats, err := e.ExportProject(ctx, db, project)
if err != nil {
t.Fatalf("Export: %v", err)
}
@ -69,11 +69,11 @@ func TestExportWritesEntries(t *testing.T) {
t.Errorf("%d Dateien geschrieben, erwartet 2", stats.Written)
}
inhalt := lies(t, ziel, "plattform", "deployment.md")
content := readFile(t, target, "plattform", "deployment.md")
// Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch
// wieder einlesen ließe.
for _, erwartet := range []string{
for _, want := range []string{
`slug: "deployment"`,
`title: "Deployment-Prozess"`,
`tags: ["deployment", "ops"]`,
@ -84,12 +84,12 @@ func TestExportWritesEntries(t *testing.T) {
"created_at:",
"updated_at:",
} {
if !strings.Contains(inhalt, erwartet) {
t.Errorf("%q fehlt im Dateikopf:\n%s", erwartet, inhalt)
if !strings.Contains(content, want) {
t.Errorf("%q fehlt im Dateikopf:\n%s", want, content)
}
}
if !strings.Contains(inhalt, "Wir deployen über GitHub Actions.") {
if !strings.Contains(content, "Wir deployen über GitHub Actions.") {
t.Error("der Inhalt fehlt")
}
}
@ -99,32 +99,32 @@ func TestExportWritesEntries(t *testing.T) {
// Ein Titel wie "Fehler: Zugriff verweigert" enthält einen Doppelpunkt und würde
// ohne Anführungszeichen die YAML-Struktur zerstören.
func TestExportEscapesYAML(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
heikel := `Fehler: "Zugriff verweigert" — siehe #42`
sensitive := `Fehler: "Zugriff verweigert" — siehe #42`
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "heikel", Title: heikel,
ProjectID: project.ID, Slug: "heikel", Title: sensitive,
Content: "Text", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
inhalt := lies(t, ziel, "plattform", "heikel.md")
content := readFile(t, target, "plattform", "heikel.md")
// Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein.
if !strings.Contains(inhalt, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) {
t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", inhalt)
if !strings.Contains(content, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) {
t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", content)
}
// Der Kopf muss weiterhin genau zwei Trennlinien haben.
if anzahl := strings.Count(inhalt, "\n---\n"); anzahl != 1 {
kopfEnde := strings.Index(inhalt[4:], "---")
if kopfEnde == -1 {
if count := strings.Count(content, "\n---\n"); count != 1 {
headEnd := strings.Index(content[4:], "---")
if headEnd == -1 {
t.Error("der Dateikopf wurde nicht sauber abgeschlossen")
}
}
@ -133,37 +133,37 @@ func TestExportEscapesYAML(t *testing.T) {
// TestExportRemovesOrphans prüft, dass gelöschte Einträge auch aus dem Export
// verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen.
func TestExportRemovesOrphans(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "bleibt", Title: "Bleibt",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "bleibt", Title: "Bleibt",
Content: "x", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "geht", Title: "Geht",
Content: "x", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err != nil {
if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err != nil {
t.Fatal("die Datei wurde gar nicht erst angelegt")
}
// Ein Eintrag im Papierkorb gehört nicht mehr in den Export.
if err := db.MoveEntryToTrash(ctx, projekt.ID, "geht", "test"); err != nil {
if err := db.MoveEntryToTrash(ctx, project.ID, "geht", "test"); err != nil {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, db, projekt)
stats, err := e.ExportProject(ctx, db, project)
if err != nil {
t.Fatal(err)
}
if stats.Removed != 1 {
t.Errorf("%d Dateien entfernt, erwartet 1", stats.Removed)
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "geht.md")); err == nil {
if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err == nil {
t.Error("die Datei des gelöschten Eintrags liegt noch da")
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "bleibt.md")); err != nil {
if _, err := os.Stat(filepath.Join(target, "plattform", "bleibt.md")); err != nil {
t.Error("beim Aufräumen wurde zu viel gelöscht")
}
}
@ -171,25 +171,25 @@ func TestExportRemovesOrphans(t *testing.T) {
// TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner
// nicht angetastet werden.
func TestExportLeavesForeignFilesAlone(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
ordner := filepath.Join(ziel, "plattform")
if err := os.MkdirAll(ordner, 0750); err != nil {
dir := filepath.Join(target, "plattform")
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err)
}
// Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben.
os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640)
os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750)
os.WriteFile(filepath.Join(dir, "notizen.txt"), []byte("privat"), 0640)
os.MkdirAll(filepath.Join(dir, "anhaenge"), 0750)
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(ordner, "notizen.txt")); err != nil {
if _, err := os.Stat(filepath.Join(dir, "notizen.txt")); err != nil {
t.Error("eine fremde Datei wurde entfernt")
}
if _, err := os.Stat(filepath.Join(ordner, "anhaenge")); err != nil {
if _, err := os.Stat(filepath.Join(dir, "anhaenge")); err != nil {
t.Error("ein Unterordner wurde entfernt")
}
}
@ -197,31 +197,31 @@ func TestExportLeavesForeignFilesAlone(t *testing.T) {
// TestExportIsStable prüft, dass ein zweiter Lauf ohne Änderungen die Dateien
// nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung.
func TestExportIsStable(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag",
Content: "Inhalt", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
pfad := filepath.Join(ziel, "plattform", "eintrag.md")
vorher, err := os.Stat(pfad)
filePath := filepath.Join(target, "plattform", "eintrag.md")
before, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
nachher, err := os.Stat(pfad)
after, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
if !vorher.ModTime().Equal(nachher.ModTime()) {
if !before.ModTime().Equal(after.ModTime()) {
t.Error("die unveränderte Datei wurde neu geschrieben")
}
}
@ -232,58 +232,58 @@ func TestExportIsStable(t *testing.T) {
// Für eine Git-Sicherung ist das der entscheidende Punkt: Ein Lauf ohne
// Änderungen darf keinen Diff erzeugen.
func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag",
Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
// Inhalt aller Dateien merken.
ordner := filepath.Join(ziel, "plattform")
vorher := make(map[string]string)
dateien, _ := os.ReadDir(ordner)
for _, d := range dateien {
vorher[d.Name()] = lies(t, ordner, d.Name())
dir := filepath.Join(target, "plattform")
before := make(map[string]string)
files, _ := os.ReadDir(dir)
for _, d := range files {
before[d.Name()] = readFile(t, dir, d.Name())
}
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
for name, alt := range vorher {
if neu := lies(t, ordner, name); neu != alt {
for name, old := range before {
if newer := readFile(t, dir, name); newer != old {
t.Errorf("%s hat sich ohne inhaltliche Änderung verändert:\n--- vorher ---\n%s\n--- nachher ---\n%s",
name, alt, neu)
name, old, newer)
}
}
}
// TestExportIndex prüft die Übersicht.
func TestExportIndex(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Alpha",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "Alpha",
Content: "x", Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta",
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "Beta",
Content: "x", Source: store.SourceAgent})
if _, err := e.ExportProject(ctx, db, projekt); err != nil {
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
index := lies(t, ziel, "plattform", "_index.md")
index := readFile(t, target, "plattform", "_index.md")
// Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged.
if strings.Contains(index, "generated_at") {
t.Error("die Übersicht trägt einen Zeitstempel und erzeugt damit bei jedem Lauf einen Diff")
}
for _, erwartet := range []string{
for _, want := range []string{
"# Plattform-Team",
"Wissen des Teams",
"## ops",
@ -291,8 +291,8 @@ func TestExportIndex(t *testing.T) {
"[Alpha](a.md)",
"[Beta](b.md)",
} {
if !strings.Contains(index, erwartet) {
t.Errorf("%q fehlt in der Übersicht:\n%s", erwartet, index)
if !strings.Contains(index, want) {
t.Errorf("%q fehlt in der Übersicht:\n%s", want, index)
}
}
}
@ -300,43 +300,43 @@ func TestExportIndex(t *testing.T) {
// TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt —
// die Abschottung gilt auch im Export.
func TestExportAllProjects(t *testing.T) {
e, db, projektA, ziel := aufbau(t)
e, db, projectA, target := setup(t)
ctx := context.Background()
// Beide Projekte liegen in derselben Workspace-Datei — dieselbe
// workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig.
projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "")
// workspace_id wie projectA reicht, eine eigene Workspace ist nicht nötig.
projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "anderes", "Anderes Team", "")
if err != nil {
t.Fatal(err)
}
db.PutEntry(ctx, store.EntryWrite{ProjectID: projektA.ID, Slug: "a-wissen", Title: "A",
db.PutEntry(ctx, store.EntryWrite{ProjectID: projectA.ID, Slug: "a-wissen", Title: "A",
Content: "gehört zu A", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B",
db.PutEntry(ctx, store.EntryWrite{ProjectID: projectB.ID, Slug: "b-wissen", Title: "B",
Content: "gehört zu B", Source: store.SourceHuman})
ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"}
ws := store.Workspace{ID: projectA.WorkspaceID, Name: "Intern"}
if _, err := e.ExportWorkspace(ctx, db, ws); err != nil {
t.Fatal(err)
}
if inhalt := lies(t, ziel, "plattform", "a-wissen.md"); !strings.Contains(inhalt, "gehört zu A") {
if content := readFile(t, target, "plattform", "a-wissen.md"); !strings.Contains(content, "gehört zu A") {
t.Error("Projekt A wurde nicht korrekt exportiert")
}
if inhalt := lies(t, ziel, "anderes", "b-wissen.md"); !strings.Contains(inhalt, "gehört zu B") {
if content := readFile(t, target, "anderes", "b-wissen.md"); !strings.Contains(content, "gehört zu B") {
t.Error("Projekt B wurde nicht korrekt exportiert")
}
// Kein Eintrag darf im Ordner des anderen Projekts liegen.
if _, err := os.Stat(filepath.Join(ziel, "plattform", "b-wissen.md")); err == nil {
if _, err := os.Stat(filepath.Join(target, "plattform", "b-wissen.md")); err == nil {
t.Error("ein Eintrag aus Projekt B landete im Ordner von Projekt A")
}
}
func TestExportEmptyProject(t *testing.T) {
e, db, projekt, ziel := aufbau(t)
e, db, project, target := setup(t)
stats, err := e.ExportProject(context.Background(), db, projekt)
stats, err := e.ExportProject(context.Background(), db, project)
if err != nil {
t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err)
}
@ -344,15 +344,15 @@ func TestExportEmptyProject(t *testing.T) {
if stats.Written != 1 {
t.Errorf("%d Dateien, erwartet 1", stats.Written)
}
if _, err := os.Stat(filepath.Join(ziel, "plattform", "_index.md")); err != nil {
if _, err := os.Stat(filepath.Join(target, "plattform", "_index.md")); err != nil {
t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen")
}
}
// TestExportTrenntWorkspaces: Zwei Workspaces mit einem Projekt gleichen
// TestExportSeparatesWorkspaces: Zwei Workspaces mit einem Projekt gleichen
// Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig
// Dateien wegräumen. Vor der Ebene je Workspace passierte genau das.
func TestExportTrenntWorkspaces(t *testing.T) {
func TestExportSeparatesWorkspaces(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
e := New(root)
@ -362,7 +362,7 @@ func TestExportTrenntWorkspaces(t *testing.T) {
ws store.Workspace
db *store.Store
}
var beide []workspace
var both []workspace
for _, name := range []string{"Vertrieb", "Technik"} {
db, err := store.Open(filepath.Join(t.TempDir(), name+".db"))
if err != nil {
@ -371,33 +371,33 @@ func TestExportTrenntWorkspaces(t *testing.T) {
t.Cleanup(func() { db.Close() })
ws := store.Workspace{ID: store.NewID(), Name: name}
projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "")
project, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "nur-" + strings.ToLower(name),
if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "nur-" + strings.ToLower(name),
Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil {
t.Fatal(err)
}
beide = append(beide, workspace{ws, db})
both = append(both, workspace{ws, db})
}
// Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die
// Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen.
for lauf := 0; lauf < 2; lauf++ {
for _, w := range beide {
for run := 0; run < 2; run++ {
for _, w := range both {
if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil {
t.Fatal(err)
}
}
}
for _, w := range beide {
datei := "nur-" + strings.ToLower(w.ws.Name) + ".md"
if inhalt := lies(t, root, w.ws.ID, "plattform", datei); !strings.Contains(inhalt, "gehört zu "+w.ws.Name) {
for _, w := range both {
file := "nur-" + strings.ToLower(w.ws.Name) + ".md"
if content := readFile(t, root, w.ws.ID, "plattform", file); !strings.Contains(content, "gehört zu "+w.ws.Name) {
t.Errorf("Eintrag von %s fehlt oder ist falsch", w.ws.Name)
}
if inhalt := lies(t, root, w.ws.ID, workspaceDateiname); !strings.Contains(inhalt, w.ws.Name) {
if content := readFile(t, root, w.ws.ID, workspaceFilename); !strings.Contains(content, w.ws.Name) {
t.Errorf("_workspace.md nennt %s nicht", w.ws.Name)
}
}

View file

@ -10,20 +10,20 @@ import (
"munin/internal/store"
)
// Datei ist eine Datei des Exports, noch nicht geschrieben.
type Datei struct {
// Pfad ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
// File ist eine Datei des Exports, noch nicht geschrieben.
type File struct {
// Path ist relativ zur Wurzel des Exports und nutzt immer "/", auch unter
// Windows — so verlangt es das ZIP-Format.
Pfad string
Inhalt string
Path string
Content string
// Geaendert ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen
// Modified ist der Zeitpunkt, den die Datei im ZIP trägt. Bei Einträgen
// ihre letzte Änderung, damit ein entpacktes Archiv in jedem Dateimanager
// die richtige Reihenfolge zeigt. Leer bedeutet: Zeitpunkt des Downloads.
Geaendert time.Time
Modified time.Time
}
// ProjektDateien stellt den Export eines einzelnen Projekts zusammen:
// ProjectFiles stellt den Export eines einzelnen Projekts zusammen:
//
// <projekt>/
// _index.md
@ -31,71 +31,71 @@ type Datei struct {
//
// Das ist genau das, was der Import eines Projekts erwartet (Unterordner
// erlaubt, Dateien mit "_" am Anfang werden übergangen).
func ProjektDateien(ctx context.Context, s *store.Store, project store.Project) ([]Datei, error) {
return projektDateien(ctx, s, project)
func ProjectFiles(ctx context.Context, s *store.Store, project store.Project) ([]File, error) {
return projectFiles(ctx, s, project)
}
// WorkspaceDateien stellt den Export aller Projekte eines Workspace zusammen,
// WorkspaceFiles stellt den Export aller Projekte eines Workspace zusammen,
// im selben Aufbau wie ein Workspace-Ordner unter EXPORT_DIR:
//
// _workspace.md
// <projekt-a>/…
// <projekt-b>/…
func WorkspaceDateien(ctx context.Context, s *store.Store, ws store.Workspace) ([]Datei, error) {
dateien := []Datei{{Pfad: workspaceDateiname, Inhalt: rendereWorkspace(ws)}}
func WorkspaceFiles(ctx context.Context, s *store.Store, ws store.Workspace) ([]File, error) {
files := []File{{Path: workspaceFilename, Content: renderWorkspace(ws)}}
projekte, err := s.ListProjects(ctx, ws.ID)
projects, err := s.ListProjects(ctx, ws.ID)
if err != nil {
return nil, err
}
for _, p := range projekte {
projekt, err := projektDateien(ctx, s, p)
for _, p := range projects {
project, err := projectFiles(ctx, s, p)
if err != nil {
return nil, fmt.Errorf("Projekt %q: %w", p.Slug, err)
}
dateien = append(dateien, projekt...)
files = append(files, project...)
}
return dateien, nil
return files, nil
}
// SchreibeZIP packt die Dateien als ZIP-Archiv nach w.
// WriteZIP packt die Dateien als ZIP-Archiv nach w.
//
// # Warum erst sammeln, dann packen?
//
// Beim Download schreibt w direkt in die Antwort an den Browser. Ist der
// erste Teil des Archivs einmal unterwegs, lässt sich ein Fehler nicht mehr
// als Fehlerseite melden — der Browser bekäme nur ein kaputtes ZIP. Deshalb
// liest der Aufrufer zuerst alles aus der Datenbank (ProjektDateien,
// WorkspaceDateien). Was danach noch schiefgehen kann, ist nur noch die
// liest der Aufrufer zuerst alles aus der Datenbank (ProjectFiles,
// WorkspaceFiles). Was danach noch schiefgehen kann, ist nur noch die
// Verbindung selbst.
func SchreibeZIP(w io.Writer, dateien []Datei) error {
jetzt := time.Now()
archiv := zip.NewWriter(w)
func WriteZIP(w io.Writer, files []File) error {
now := time.Now()
archive := zip.NewWriter(w)
for _, d := range dateien {
geaendert := d.Geaendert
if geaendert.IsZero() {
geaendert = jetzt
for _, d := range files {
changed := d.Modified
if changed.IsZero() {
changed = now
}
// CreateHeader statt Create, um Komprimierung und Zeitstempel
// selbst festzulegen. Deflate ist das Verfahren, das jedes
// Entpackprogramm kennt.
f, err := archiv.CreateHeader(&zip.FileHeader{
Name: d.Pfad,
f, err := archive.CreateHeader(&zip.FileHeader{
Name: d.Path,
Method: zip.Deflate,
Modified: geaendert,
Modified: changed,
})
if err != nil {
return err
}
if _, err := io.WriteString(f, d.Inhalt); err != nil {
if _, err := io.WriteString(f, d.Content); err != nil {
return err
}
}
// Close schreibt das Inhaltsverzeichnis ans Ende des Archivs. Ohne diesen
// Schritt ist das ZIP unbrauchbar, auch wenn alle Dateien darin stehen.
return archiv.Close()
return archive.Close()
}

141
internal/help/help.go Normal file
View file

@ -0,0 +1,141 @@
// Package help enthält die eingebaute Onboarding-Dokumentation der
// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe
// bedeuten.
//
// # Warum eingebettet und nicht als Einträge in einem Projekt?
//
// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung
// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben
// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie
// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand
// anlegen.
//
// Die Seiten sind Markdown-Dateien im Ordner pages/. //go:embed packt sie
// beim Kompilieren ins Binary, genau wie templates/ und static/.
package help
import (
"embed"
"fmt"
"munin/internal/markdown"
)
//go:embed pages/*.md
var pagesFS embed.FS
// Page beschreibt eine Hilfeseite.
type Page struct {
Slug string // Teil der Adresse: /hilfe/<Slug>
Title string // Überschrift und Name in der Navigation
Summary string // ein Satz für die Übersicht
// For legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie
// weder in der Navigation, noch lässt sie sich direkt aufrufen (404).
// Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar
// nicht sehen.
For Audience
}
// Audience einer Hilfeseite.
type Audience int
const (
// ForEveryone: jeder, der angemeldet ist.
ForEveryone Audience = iota
// ForAdmins: Workspace-Admins und der Plattform-Admin.
ForAdmins
// ForPlatformAdmin: nur der Plattform-Admin.
ForPlatformAdmin
)
// Reader beschreibt, wer die Hilfe gerade liest — so viel, wie für die
// Auswahl der Seiten nötig ist.
type Reader struct {
PlatformAdmin bool
WorkspaceAdmin bool
}
// CanRead sagt, ob l die Seite sehen darf.
func (s Page) CanRead(l Reader) bool {
switch s.For {
case ForPlatformAdmin:
return l.PlatformAdmin
case ForAdmins:
return l.PlatformAdmin || l.WorkspaceAdmin
default:
return true
}
}
// pages legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer
// Page heißt immer pages/<Slug>.md — so gibt es nur eine Stelle, an der ein
// Name steht, und der Test in help_test.go prüft, dass beides zusammenpasst.
var pages = []Page{
{Slug: "start", Title: "Willkommen", Summary: "Was Munin ist und wo man anfängt."},
{Slug: "begriffe", Title: "Begriffe", Summary: "Workspace, Projekt, Eintrag, Bezeichner, Rollen — was sie bedeuten."},
{Slug: "mcp-einrichten", Title: "Agent anbinden", Summary: "Claude Code, Cline oder OpenCode mit Munin verbinden."},
{Slug: "mit-agenten-arbeiten", Title: "Mit Agenten arbeiten", Summary: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."},
{Slug: "weboberflaeche", Title: "Weboberfläche", Summary: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."},
{Slug: "zugaenge", Title: "Zugänge und Sicherheit", Summary: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."},
{Slug: "workspace-admin", Title: "Für Workspace-Admins", Summary: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", For: ForAdmins},
{Slug: "plattform-admin", Title: "Für Plattform-Admins", Summary: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", For: ForPlatformAdmin},
}
// All gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die
// nur für Admins. Für die Anzeige ist Sichtbar die richtige Funktion.
//
// Zurückgegeben wird eine Kopie: Wer die Liste verändert, soll damit nicht aus
// Versehen die Navigation aller anderen Anfragen verändern.
func All() []Page {
clone := make([]Page, len(pages))
copy(clone, pages)
return clone
}
// Visible gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der
// Navigation.
func Visible(l Reader) []Page {
var result []Page
for _, s := range pages {
if s.CanRead(l) {
result = append(result, s)
}
}
return result
}
// Find sucht eine Seite über ihren Slug.
func Find(slug string) (Page, bool) {
for _, s := range pages {
if s.Slug == slug {
return s, true
}
}
return Page{}, false
}
// Markdown liefert den Rohtext einer Seite.
func (s Page) Markdown() (string, error) {
content, err := pagesFS.ReadFile("pages/" + s.Slug + ".md")
if err != nil {
return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err)
}
return string(content), nil
}
// Render wandelt eine Hilfeseite in HTML.
//
// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die
// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger
// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit
// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa
// /hilfe/weboberflaeche#markdown-im-inhalt.
//
// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem
// Repository und nicht von Nutzern, aber die Platzhalter (siehe
// web/handlers_help.go) setzen Werte aus der Anfrage ein — und dann soll
// daraus auf keinen Fall HTML werden können.
func Render(source string) (string, error) {
return markdown.RenderWithAnchors(source)
}

188
internal/help/help_test.go Normal file
View file

@ -0,0 +1,188 @@
package help
import (
"io/fs"
"regexp"
"strings"
"testing"
)
// TestEveryPageHasAFile stellt sicher, dass die Liste in help.go und die
// Dateien in pages/ zusammenpassen — in beide Richtungen. Eine Seite ohne
// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre
// unerreichbar und würde unbemerkt veralten.
func TestEveryPageHasAFile(t *testing.T) {
listed := map[string]bool{}
for _, s := range All() {
listed[s.Slug] = true
if _, err := s.Markdown(); err != nil {
t.Errorf("Seite %q: %v", s.Slug, err)
}
}
files, err := fs.Glob(pagesFS, "pages/*.md")
if err != nil {
t.Fatal(err)
}
for _, d := range files {
slug := strings.TrimSuffix(strings.TrimPrefix(d, "pages/"), ".md")
if !listed[slug] {
t.Errorf("Datei %s steht nicht in der Seitenliste", d)
}
}
}
// TestInternalLinksLeadToPages prüft jeden Link der Form (/hilfe/…) in
// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier
// auf statt erst als 404 beim Lesen.
func TestInternalLinksLeadToPages(t *testing.T) {
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`)
for _, s := range All() {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
for _, hits := range link.FindAllStringSubmatch(md, -1) {
if _, ok := Find(hits[1]); !ok {
t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, hits[1])
}
}
}
}
func TestFindUnknownPage(t *testing.T) {
if _, ok := Find("gibt-es-nicht"); ok {
t.Error("Finde meldet eine Seite, die es nicht gibt")
}
}
func TestRenderTablesAndNoRawHTML(t *testing.T) {
html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "<table>") {
t.Error("Tabellen werden nicht als Tabelle dargestellt")
}
if strings.Contains(html, "<script>") {
t.Error("Roh-HTML wird durchgelassen")
}
}
// TestAllReturnsCopy stellt sicher, dass ein Aufrufer die Navigation nicht
// für alle anderen verändern kann.
func TestAllReturnsCopy(t *testing.T) {
a := All()
a[0].Title = "verändert"
if All()[0].Title == "verändert" {
t.Error("Alle gibt die interne Liste statt einer Kopie heraus")
}
}
// Die drei Lesergruppen, für die Tests.
var (
projectReader = Reader{}
workspaceAdmin = Reader{WorkspaceAdmin: true}
platformAdmin = Reader{PlatformAdmin: true}
)
func TestVisiblePerAudience(t *testing.T) {
contains := func(pages []Page, slug string) bool {
for _, s := range pages {
if s.Slug == slug {
return true
}
}
return false
}
for _, f := range []struct {
name string
reader Reader
workspacePage, platformPage bool
}{
{"Projektzugang", projectReader, false, false},
{"Workspace-Admin", workspaceAdmin, true, false},
{"Plattform-Admin", platformAdmin, true, true},
} {
visible := Visible(f.reader)
if contains(visible, "workspace-admin") != f.workspacePage {
t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspacePage)
}
if contains(visible, "plattform-admin") != f.platformPage {
t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.platformPage)
}
if !contains(visible, "start") {
t.Errorf("%s sieht die Startseite nicht", f.name)
}
}
if len(Visible(platformAdmin)) != len(All()) {
t.Error("der Plattform-Admin sieht nicht alle Seiten")
}
}
// TestPagesLinkOnlyVisible: Ein Link auf eine Seite, die der Leser
// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede
// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht.
func TestPagesLinkOnlyVisible(t *testing.T) {
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`)
for _, reader := range []Reader{projectReader, workspaceAdmin} {
for _, s := range Visible(reader) {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
for _, hits := range link.FindAllStringSubmatch(md, -1) {
if target, ok := Find(hits[1]); ok && !target.CanRead(reader) {
t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, target.Slug, reader)
}
}
}
}
}
// TestLinkAnchorsExist: Jeder Link auf einen Abschnitt einer Hilfeseite —
// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf
// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite
// statt beim gemeinten Abschnitt, und niemand merkt es.
//
// Die Hilfe-Links der Templates prüft TestHelpLinksPointToExistingSections
// in internal/web.
func TestLinkAnchorsExist(t *testing.T) {
idPattern := regexp.MustCompile(`<h[1-6] id="([^"]+)"`)
anchor := map[string]map[string]bool{}
for _, s := range All() {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
html, err := Render(md)
if err != nil {
t.Fatal(err)
}
anchor[s.Slug] = map[string]bool{}
for _, m := range idPattern.FindAllStringSubmatch(html, -1) {
anchor[s.Slug][m[1]] = true
}
}
link := regexp.MustCompile(`\]\((?:/hilfe/([a-z0-9-]+))?#([a-z0-9-]+)\)`)
for _, s := range All() {
md, _ := s.Markdown()
for _, m := range link.FindAllStringSubmatch(md, -1) {
target := m[1]
if target == "" {
target = s.Slug
}
if _, exists := anchor[target]; !exists {
t.Errorf("%s: Link auf unbekannte Seite %q", s.Slug, target)
continue
}
if !anchor[target][m[2]] {
t.Errorf("%s: Abschnitt #%s gibt es auf %q nicht", s.Slug, m[2], target)
}
}
}
}

View file

@ -12,6 +12,11 @@
weitere Keys für ihr Team und ihre Agenten an. Der Verweis auf diese Hilfe
(`{{SERVER}}/hilfe`) reicht als Einstieg.
Deine Anmeldung als Plattform-Admin gilt 12 Stunden, danach fragt Munin
erneut nach dem ADMIN_TOKEN. Das ist Absicht: Anders als ein Key lässt sich
diese Sitzung nicht einzeln widerrufen, und sie kommt an alles. Ein Wechsel des
Workspace verlängert sie nicht.
Soll eine Abteilung ihre Projekte selbst anlegen, reicht es, ihr einen
[Workspace-Admin](#workspace-admins) einzurichten. Die Schritte 2 und 3
übernimmt dann sie.

View file

@ -1,141 +0,0 @@
// Package hilfe enthält die eingebaute Onboarding-Dokumentation der
// Weboberfläche: wie man Munin einrichtet, benutzt und was die Begriffe
// bedeuten.
//
// # Warum eingebettet und nicht als Einträge in einem Projekt?
//
// Die Hilfe beschreibt das Programm selbst. Sie muss also zu genau der Fassung
// passen, die gerade läuft — ändert sich eine Funktion, ändert sich im selben
// Commit auch ihre Beschreibung. Als Einträge in der Datenbank würde sie
// unbemerkt veralten, und in jedem neuen Workspace müsste sie erst jemand
// anlegen.
//
// Die Seiten sind Markdown-Dateien im Ordner seiten/. //go:embed packt sie
// beim Kompilieren ins Binary, genau wie templates/ und static/.
package hilfe
import (
"embed"
"fmt"
"munin/internal/markdown"
)
//go:embed seiten/*.md
var seitenFS embed.FS
// Seite beschreibt eine Hilfeseite.
type Seite struct {
Slug string // Teil der Adresse: /hilfe/<Slug>
Titel string // Überschrift und Name in der Navigation
Kurz string // ein Satz für die Übersicht
// Fuer legt fest, wer die Seite sieht. Wer nicht dazugehört, findet sie
// weder in der Navigation, noch lässt sie sich direkt aufrufen (404).
// Geheim ist darin nichts — aber sie beschreibt Knöpfe, die andere gar
// nicht sehen.
Fuer Zielgruppe
}
// Zielgruppe einer Hilfeseite.
type Zielgruppe int
const (
// FuerAlle: jeder, der angemeldet ist.
FuerAlle Zielgruppe = iota
// FuerAdmins: Workspace-Admins und der Plattform-Admin.
FuerAdmins
// FuerPlattformAdmin: nur der Plattform-Admin.
FuerPlattformAdmin
)
// Leser beschreibt, wer die Hilfe gerade liest — so viel, wie für die
// Auswahl der Seiten nötig ist.
type Leser struct {
PlattformAdmin bool
WorkspaceAdmin bool
}
// DarfLesen sagt, ob l die Seite sehen darf.
func (s Seite) DarfLesen(l Leser) bool {
switch s.Fuer {
case FuerPlattformAdmin:
return l.PlattformAdmin
case FuerAdmins:
return l.PlattformAdmin || l.WorkspaceAdmin
default:
return true
}
}
// seiten legt Reihenfolge und Inhalt der Navigation fest. Die Datei zu einer
// Seite heißt immer seiten/<Slug>.md — so gibt es nur eine Stelle, an der ein
// Name steht, und der Test in hilfe_test.go prüft, dass beides zusammenpasst.
var seiten = []Seite{
{Slug: "start", Titel: "Willkommen", Kurz: "Was Munin ist und wo man anfängt."},
{Slug: "begriffe", Titel: "Begriffe", Kurz: "Workspace, Projekt, Eintrag, Bezeichner, Rollen — was sie bedeuten."},
{Slug: "mcp-einrichten", Titel: "Agent anbinden", Kurz: "Claude Code, Cline oder OpenCode mit Munin verbinden."},
{Slug: "mit-agenten-arbeiten", Titel: "Mit Agenten arbeiten", Kurz: "Wie man Agenten anspricht, damit sie das Gedächtnis wirklich nutzen."},
{Slug: "weboberflaeche", Titel: "Weboberfläche", Kurz: "Einträge im Browser suchen, schreiben, vergleichen und verknüpfen."},
{Slug: "zugaenge", Titel: "Zugänge und Sicherheit", Kurz: "Keys, Rollen, Protokoll und was man beim Teilen von Wissen bedenken sollte."},
{Slug: "workspace-admin", Titel: "Für Workspace-Admins", Kurz: "Projekte und Zugänge einer Abteilung verwalten, Protokoll, Suche über alle Projekte.", Fuer: FuerAdmins},
{Slug: "plattform-admin", Titel: "Für Plattform-Admins", Kurz: "Workspaces, Workspace-Admins, Schreibbudget, System, Sicherung.", Fuer: FuerPlattformAdmin},
}
// Alle gibt alle Seiten in der Reihenfolge der Navigation zurück, auch die
// nur für Admins. Für die Anzeige ist Sichtbar die richtige Funktion.
//
// Zurückgegeben wird eine Kopie: Wer die Liste verändert, soll damit nicht aus
// Versehen die Navigation aller anderen Anfragen verändern.
func Alle() []Seite {
kopie := make([]Seite, len(seiten))
copy(kopie, seiten)
return kopie
}
// Sichtbar gibt die Seiten zurück, die l sehen darf, in der Reihenfolge der
// Navigation.
func Sichtbar(l Leser) []Seite {
var ergebnis []Seite
for _, s := range seiten {
if s.DarfLesen(l) {
ergebnis = append(ergebnis, s)
}
}
return ergebnis
}
// Finde sucht eine Seite über ihren Slug.
func Finde(slug string) (Seite, bool) {
for _, s := range seiten {
if s.Slug == slug {
return s, true
}
}
return Seite{}, false
}
// Markdown liefert den Rohtext einer Seite.
func (s Seite) Markdown() (string, error) {
inhalt, err := seitenFS.ReadFile("seiten/" + s.Slug + ".md")
if err != nil {
return "", fmt.Errorf("Hilfeseite %q fehlt: %w", s.Slug, err)
}
return string(inhalt), nil
}
// Render wandelt eine Hilfeseite in HTML.
//
// Es ist derselbe Renderer wie für Einträge (internal/markdown), damit die
// Hilfe nichts zeigt, was im eigenen Eintrag dann anders aussieht. Einziger
// Unterschied: Überschriften bekommen hier eine id, damit die Oberfläche mit
// "?"-Links direkt auf einen Abschnitt zeigen kann, etwa
// /hilfe/weboberflaeche#markdown-im-inhalt.
//
// Roh-HTML bleibt auch hier gesperrt. Die Seiten stammen zwar aus dem
// Repository und nicht von Nutzern, aber die Platzhalter (siehe
// web/handlers_hilfe.go) setzen Werte aus der Anfrage ein — und dann soll
// daraus auf keinen Fall HTML werden können.
func Render(quelle string) (string, error) {
return markdown.RenderMitAnkern(quelle)
}

View file

@ -1,188 +0,0 @@
package hilfe
import (
"io/fs"
"regexp"
"strings"
"testing"
)
// TestJedeSeiteHatEineDatei stellt sicher, dass die Liste in hilfe.go und die
// Dateien in seiten/ zusammenpassen — in beide Richtungen. Eine Seite ohne
// Datei gäbe einen Fehler beim Aufruf, eine Datei ohne Listeneintrag wäre
// unerreichbar und würde unbemerkt veralten.
func TestJedeSeiteHatEineDatei(t *testing.T) {
gelistet := map[string]bool{}
for _, s := range Alle() {
gelistet[s.Slug] = true
if _, err := s.Markdown(); err != nil {
t.Errorf("Seite %q: %v", s.Slug, err)
}
}
dateien, err := fs.Glob(seitenFS, "seiten/*.md")
if err != nil {
t.Fatal(err)
}
for _, d := range dateien {
slug := strings.TrimSuffix(strings.TrimPrefix(d, "seiten/"), ".md")
if !gelistet[slug] {
t.Errorf("Datei %s steht nicht in der Seitenliste", d)
}
}
}
// TestInterneLinksFuehrenZuSeiten prüft jeden Link der Form (/hilfe/…) in
// allen Seiten. Wird eine Seite umbenannt, fällt ein vergessener Verweis hier
// auf statt erst als 404 beim Lesen.
func TestInterneLinksFuehrenZuSeiten(t *testing.T) {
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)\)`)
for _, s := range Alle() {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
for _, treffer := range link.FindAllStringSubmatch(md, -1) {
if _, ok := Finde(treffer[1]); !ok {
t.Errorf("Seite %q verlinkt auf unbekannte Hilfeseite %q", s.Slug, treffer[1])
}
}
}
}
func TestFindeUnbekannteSeite(t *testing.T) {
if _, ok := Finde("gibt-es-nicht"); ok {
t.Error("Finde meldet eine Seite, die es nicht gibt")
}
}
func TestRenderTabellenUndKeinRohHTML(t *testing.T) {
html, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n\n<script>alert(1)</script>")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "<table>") {
t.Error("Tabellen werden nicht als Tabelle dargestellt")
}
if strings.Contains(html, "<script>") {
t.Error("Roh-HTML wird durchgelassen")
}
}
// TestAlleGibtKopie stellt sicher, dass ein Aufrufer die Navigation nicht
// für alle anderen verändern kann.
func TestAlleGibtKopie(t *testing.T) {
a := Alle()
a[0].Titel = "verändert"
if Alle()[0].Titel == "verändert" {
t.Error("Alle gibt die interne Liste statt einer Kopie heraus")
}
}
// Die drei Lesergruppen, für die Tests.
var (
projektLeser = Leser{}
workspaceAdmin = Leser{WorkspaceAdmin: true}
plattformAdmin = Leser{PlattformAdmin: true}
)
func TestSichtbarJeLesergruppe(t *testing.T) {
enthaelt := func(seiten []Seite, slug string) bool {
for _, s := range seiten {
if s.Slug == slug {
return true
}
}
return false
}
for _, f := range []struct {
name string
leser Leser
workspaceSeite, plattSei bool
}{
{"Projektzugang", projektLeser, false, false},
{"Workspace-Admin", workspaceAdmin, true, false},
{"Plattform-Admin", plattformAdmin, true, true},
} {
sichtbar := Sichtbar(f.leser)
if enthaelt(sichtbar, "workspace-admin") != f.workspaceSeite {
t.Errorf("%s: Seite für Workspace-Admins sichtbar = %v", f.name, !f.workspaceSeite)
}
if enthaelt(sichtbar, "plattform-admin") != f.plattSei {
t.Errorf("%s: Seite für Plattform-Admins sichtbar = %v", f.name, !f.plattSei)
}
if !enthaelt(sichtbar, "start") {
t.Errorf("%s sieht die Startseite nicht", f.name)
}
}
if len(Sichtbar(plattformAdmin)) != len(Alle()) {
t.Error("der Plattform-Admin sieht nicht alle Seiten")
}
}
// TestSeitenVerlinkenNurSichtbares: Ein Link auf eine Seite, die der Leser
// nicht sehen darf, liefe für ihn ins Leere (404). Geprüft für jede
// Lesergruppe: Was sie sieht, verlinkt nur auf das, was sie auch sieht.
func TestSeitenVerlinkenNurSichtbares(t *testing.T) {
link := regexp.MustCompile(`\(/hilfe/([a-z0-9-]+)[#)]`)
for _, leser := range []Leser{projektLeser, workspaceAdmin} {
for _, s := range Sichtbar(leser) {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
for _, treffer := range link.FindAllStringSubmatch(md, -1) {
if ziel, ok := Finde(treffer[1]); ok && !ziel.DarfLesen(leser) {
t.Errorf("Seite %q verlinkt auf %q, die dieser Leser nicht sieht (%+v)", s.Slug, ziel.Slug, leser)
}
}
}
}
}
// TestAnkerInLinksGibtEs: Jeder Link auf einen Abschnitt einer Hilfeseite —
// (/hilfe/seite#abschnitt) oder innerhalb der Seite (#abschnitt) — zeigt auf
// eine Überschrift, die es dort gibt. Sonst landet man oben auf der Seite
// statt beim gemeinten Abschnitt, und niemand merkt es.
//
// Die Hilfe-Links der Templates prüft TestHilfelinksZeigenAufVorhandeneAbschnitte
// in internal/web.
func TestAnkerInLinksGibtEs(t *testing.T) {
idMuster := regexp.MustCompile(`<h[1-6] id="([^"]+)"`)
anker := map[string]map[string]bool{}
for _, s := range Alle() {
md, err := s.Markdown()
if err != nil {
t.Fatal(err)
}
html, err := Render(md)
if err != nil {
t.Fatal(err)
}
anker[s.Slug] = map[string]bool{}
for _, m := range idMuster.FindAllStringSubmatch(html, -1) {
anker[s.Slug][m[1]] = true
}
}
link := regexp.MustCompile(`\]\((?:/hilfe/([a-z0-9-]+))?#([a-z0-9-]+)\)`)
for _, s := range Alle() {
md, _ := s.Markdown()
for _, m := range link.FindAllStringSubmatch(md, -1) {
ziel := m[1]
if ziel == "" {
ziel = s.Slug
}
if _, gibt := anker[ziel]; !gibt {
t.Errorf("%s: Link auf unbekannte Seite %q", s.Slug, ziel)
continue
}
if !anker[ziel][m[2]] {
t.Errorf("%s: Abschnitt #%s gibt es auf %q nicht", s.Slug, m[2], ziel)
}
}
}
}

View file

@ -17,25 +17,25 @@ import (
// tatsächlich ausgepackt wird, nicht was das Archiv über sich behauptet: Die
// Größenangaben im Inneren eines ZIP-Archivs kann jeder frei hineinschreiben.
const (
// MaxDateien ist die Höchstzahl an Markdown-Dateien je Import.
MaxDateien = 5000
// MaxFiles ist die Höchstzahl an Markdown-Dateien je Import.
MaxFiles = 5000
// MaxDateiBytes entspricht der Grenze für ein Formular der Weboberfläche.
// MaxFileBytes entspricht der Grenze für ein Formular der Weboberfläche.
// Ein Eintrag, der sich nicht im Browser anlegen ließe, soll auch über
// den Import nicht hineinkommen.
MaxDateiBytes = 1 << 20 // 1 MB
MaxFileBytes = 1 << 20 // 1 MB
// MaxGesamtBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxGesamtBytes = 100 << 20 // 100 MB
// MaxTotalBytes begrenzt den ausgepackten Inhalt insgesamt.
MaxTotalBytes = 100 << 20 // 100 MB
)
// Datei ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type Datei struct {
Pfad string
Inhalt []byte
// File ist eine Markdown-Datei aus dem Archiv, noch nicht ausgewertet.
type File struct {
Path string
Content []byte
}
// LeseZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
// ReadZip liest alle Markdown-Dateien aus einem ZIP-Archiv.
//
// Übergangen werden:
// - alles, was nicht auf .md oder .markdown endet (Bilder, PDFs, …)
@ -47,76 +47,76 @@ type Datei struct {
// Unterordner sind erlaubt. So lässt sich ein ganzer Doku-Ordner auf einmal
// hochladen. Die Einträge landen trotzdem alle nebeneinander im Projekt,
// denn Munin kennt keine Ordner.
func LeseZip(daten []byte) ([]Datei, error) {
archiv, err := zip.NewReader(bytes.NewReader(daten), int64(len(daten)))
func ReadZip(data []byte) ([]File, error) {
archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, errors.New("die Datei ist kein lesbares ZIP-Archiv")
}
var dateien []Datei
var gesamt int64
var files []File
var total int64
for _, f := range archiv.File {
if f.FileInfo().IsDir() || !istEintragsdatei(f.Name) {
for _, f := range archive.File {
if f.FileInfo().IsDir() || !isEntryFile(f.Name) {
continue
}
if len(dateien) >= MaxDateien {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxDateien)
if len(files) >= MaxFiles {
return nil, fmt.Errorf("das Archiv enthält mehr als %d Markdown-Dateien — bitte aufteilen", MaxFiles)
}
inhalt, err := leseBegrenzt(f, MaxDateiBytes)
content, err := readLimited(f, MaxFileBytes)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Name, err)
}
gesamt += int64(len(inhalt))
if gesamt > MaxGesamtBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxGesamtBytes>>20)
total += int64(len(content))
if total > MaxTotalBytes {
return nil, fmt.Errorf("das Archiv ist ausgepackt größer als %d MB — bitte aufteilen", MaxTotalBytes>>20)
}
dateien = append(dateien, Datei{Pfad: f.Name, Inhalt: inhalt})
files = append(files, File{Path: f.Name, Content: content})
}
if len(dateien) == 0 {
if len(files) == 0 {
return nil, errors.New("im Archiv liegt keine Markdown-Datei (.md)")
}
return dateien, nil
return files, nil
}
// istEintragsdatei entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe LeseZip).
func istEintragsdatei(pfad string) bool {
for _, teil := range strings.Split(pfad, "/") {
if strings.HasPrefix(teil, ".") || teil == "__MACOSX" {
// isEntryFile entscheidet anhand des Pfads, ob eine Datei ein Eintrag
// werden soll (siehe ReadZip).
func isEntryFile(filePath string) bool {
for _, part := range strings.Split(filePath, "/") {
if strings.HasPrefix(part, ".") || part == "__MACOSX" {
return false
}
}
name := path.Base(pfad)
name := path.Base(filePath)
if strings.HasPrefix(name, "_") {
return false
}
endung := strings.ToLower(path.Ext(name))
return endung == ".md" || endung == ".markdown"
suffix := strings.ToLower(path.Ext(name))
return suffix == ".md" || suffix == ".markdown"
}
// leseBegrenzt packt eine Datei aus und bricht ab, sobald sie größer als
// grenze wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// readLimited packt eine Datei aus und bricht ab, sobald sie größer als
// limit wird. Gelesen wird ein Byte mehr als erlaubt: Kommt es an, ist die
// Datei zu groß.
func leseBegrenzt(f *zip.File, grenze int64) ([]byte, error) {
func readLimited(f *zip.File, limit int64) ([]byte, error) {
r, err := f.Open()
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
defer r.Close()
inhalt, err := io.ReadAll(io.LimitReader(r, grenze+1))
content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, fmt.Errorf("konnte nicht ausgepackt werden: %w", err)
}
if int64(len(inhalt)) > grenze {
return nil, fmt.Errorf("größer als %d KB", grenze>>10)
if int64(len(content)) > limit {
return nil, fmt.Errorf("größer als %d KB", limit>>10)
}
return inhalt, nil
return content, nil
}

View file

@ -35,150 +35,150 @@ import (
"munin/internal/store"
)
// Modus legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
type Modus string
// Mode legt fest, was mit Dateien geschieht, deren Bezeichner schon belegt ist.
type Mode string
const (
ModusUeberspringen = Modus("skip")
ModusUeberschreiben = Modus("overwrite")
ModeSkip = Mode("skip")
ModeOverwrite = Mode("overwrite")
)
// Aktion ist das Ergebnis für eine einzelne Datei.
type Aktion string
// Action ist das Ergebnis für eine einzelne Datei.
type Action string
const (
AktionNeu = Aktion("neu")
AktionUeberschrieben = Aktion("überschrieben")
AktionUnveraendert = Aktion("unverändert")
AktionUebersprungen = Aktion("übersprungen")
AktionFehler = Aktion("Fehler")
ActionCreated = Action("neu")
ActionOverwritten = Action("überschrieben")
ActionUnchanged = Action("unverändert")
ActionSkipped = Action("übersprungen")
ActionFailed = Action("Fehler")
)
// Optionen steuern einen Import.
type Optionen struct {
Modus Modus
// Options steuern einen Import.
type Options struct {
Mode Mode
// Vorschau: nur ausrechnen, was passieren würde, nichts schreiben.
Vorschau bool
// Preview: nur ausrechnen, was passieren würde, nichts schreiben.
Preview bool
// Autor steht an Einträgen, deren Datei keinen Autor nennt, etwa bei
// Author steht an Einträgen, deren Datei keinen Autor nennt, etwa bei
// vorhandener Dokumentation. Üblich ist der Name des importierenden
// Zugangs.
Autor string
Author string
}
// Zeile ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
type Zeile struct {
Pfad string
// Row ist das Ergebnis für eine Datei, so wie die Oberfläche es anzeigt.
type Row struct {
Path string
Slug string
Title string
Aktion Aktion
Grund string // Erläuterung, etwa bei Fehler oder Übersprungen
Action Action
Reason string // Erläuterung, etwa bei Fehler oder Übersprungen
}
// Bericht fasst einen Import zusammen.
type Bericht struct {
Zeilen []Zeile
// Report fasst einen Import zusammen.
type Report struct {
Rows []Row
Neu int
Ueberschrieben int
Unveraendert int
Uebersprungen int
Fehler int
Created int
Overwritten int
Unchanged int
Skipped int
Failed int
// Geschrieben sind die tatsächlich geschriebenen Einträge. Der Aufrufer
// Written sind die tatsächlich geschriebenen Einträge. Der Aufrufer
// braucht sie, um den Suchindex nachzuziehen. Bei einer Vorschau leer.
Geschrieben []store.Entry
Written []store.Entry
}
// Zusammenfassung ist eine Zeile für Meldung und Protokoll.
func (b Bericht) Zusammenfassung() string {
// Summary ist eine Zeile für Meldung und Protokoll.
func (b Report) Summary() string {
return fmt.Sprintf("%d neu, %d überschrieben, %d unverändert, %d übersprungen, %d Fehler",
b.Neu, b.Ueberschrieben, b.Unveraendert, b.Uebersprungen, b.Fehler)
b.Created, b.Overwritten, b.Unchanged, b.Skipped, b.Failed)
}
// Importiere liest die Dateien in das Projekt ein, oder rechnet bei
// opt.Vorschau nur aus, was passieren würde.
// Import liest die Dateien in das Projekt ein, oder rechnet bei
// opt.Preview nur aus, was passieren würde.
//
// Ein Fehler in einer einzelnen Datei bricht den Import nicht ab. Er steht im
// Bericht, und die übrigen Dateien laufen weiter. Nur ein Fehler der
// Report, und die übrigen Dateien laufen weiter. Nur ein Fehler der
// Datenbank selbst beendet den Import. Was bis dahin geschrieben war, bleibt
// geschrieben: Jeder Eintrag ist für sich vollständig oder gar nicht da (eine
// Transaktion je Eintrag), aber der Import als Ganzes ist keine Transaktion.
// Ihn danach im Modus „Überspringen“ zu wiederholen, macht den Rest.
func Importiere(ctx context.Context, s *store.Store, projectID string, dateien []Datei, opt Optionen) (Bericht, error) {
var b Bericht
func Import(ctx context.Context, s *store.Store, projectID string, files []File, opt Options) (Report, error) {
var b Report
if opt.Modus != ModusUeberspringen && opt.Modus != ModusUeberschreiben {
return b, fmt.Errorf("unbekannter Modus %q", opt.Modus)
if opt.Mode != ModeSkip && opt.Mode != ModeOverwrite {
return b, fmt.Errorf("unbekannter Modus %q", opt.Mode)
}
// Den Bestand einmal vorab laden statt je Datei nachzufragen: Bei einigen
// tausend Dateien sind das zwei Abfragen statt einiger tausend.
vorhanden, err := s.AllEntries(ctx, projectID)
existing, err := s.AllEntries(ctx, projectID)
if err != nil {
return b, err
}
bestand := make(map[string]store.Entry, len(vorhanden))
for _, e := range vorhanden {
bestand[e.Slug] = e
inventory := make(map[string]store.Entry, len(existing))
for _, e := range existing {
inventory[e.Slug] = e
}
// Einträge im Papierkorb belegen ihren Bezeichner weiter (siehe
// store.PutEntry). Sie müssen eigens erkannt werden: Sonst holte ein
// Import im Modus „Überspringen“ gelöschte Einträge stillschweigend
// zurück, weil sie für AllEntries nicht existieren.
geloescht, err := s.ListTrash(ctx, projectID, "")
deleted, err := s.ListTrash(ctx, projectID, "")
if err != nil {
return b, err
}
imPapierkorb := make(map[string]bool, len(geloescht))
for _, e := range geloescht {
imPapierkorb[e.Slug] = true
inTrash := make(map[string]bool, len(deleted))
for _, e := range deleted {
inTrash[e.Slug] = true
}
// Welche Datei hat welchen Bezeichner schon belegt? Zwei Dateien mit
// demselben Bezeichner in einem Archiv (etwa "ops/deploy.md" und
// "alt/deploy.md") würden sich sonst gegenseitig überschreiben.
belegtDurch := make(map[string]string)
usedBy := make(map[string]string)
for _, d := range dateien {
e, err := Parse(d.Pfad, d.Inhalt)
zeile := Zeile{Pfad: d.Pfad, Slug: e.Slug, Title: e.Title}
for _, d := range files {
e, err := Parse(d.Path, d.Content)
line := Row{Path: d.Path, Slug: e.Slug, Title: e.Title}
switch {
case err != nil:
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
line.Action, line.Reason = ActionFailed, err.Error()
case belegtDurch[e.Slug] != "":
zeile.Aktion = AktionFehler
zeile.Grund = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, belegtDurch[e.Slug])
case usedBy[e.Slug] != "":
line.Action = ActionFailed
line.Reason = fmt.Sprintf("Bezeichner %q kommt schon in %s vor", e.Slug, usedBy[e.Slug])
default:
belegtDurch[e.Slug] = d.Pfad
alt, gibtEs := bestand[e.Slug]
usedBy[e.Slug] = d.Path
old, exists := inventory[e.Slug]
switch {
case gibtEs && gleicherInhalt(alt, e):
zeile.Aktion = AktionUnveraendert
case (gibtEs || imPapierkorb[e.Slug]) && opt.Modus == ModusUeberspringen:
zeile.Aktion = AktionUebersprungen
zeile.Grund = "Bezeichner schon vorhanden"
if imPapierkorb[e.Slug] {
zeile.Grund = "Bezeichner gehört einem Eintrag im Papierkorb"
case exists && sameContent(old, e):
line.Action = ActionUnchanged
case (exists || inTrash[e.Slug]) && opt.Mode == ModeSkip:
line.Action = ActionSkipped
line.Reason = "Bezeichner schon vorhanden"
if inTrash[e.Slug] {
line.Reason = "Bezeichner gehört einem Eintrag im Papierkorb"
}
case gibtEs || imPapierkorb[e.Slug]:
zeile.Aktion = AktionUeberschrieben
zeile.Grund = "bisheriger Stand steht im Verlauf"
if imPapierkorb[e.Slug] {
zeile.Grund = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
case exists || inTrash[e.Slug]:
line.Action = ActionOverwritten
line.Reason = "bisheriger Stand steht im Verlauf"
if inTrash[e.Slug] {
line.Reason = "aus dem Papierkorb zurückgeholt, gelöschter Stand steht im Verlauf"
}
default:
zeile.Aktion = AktionNeu
line.Action = ActionCreated
}
if !opt.Vorschau && (zeile.Aktion == AktionNeu || zeile.Aktion == AktionUeberschrieben) {
geschrieben, err := s.PutEntry(ctx, schreibauftrag(projectID, e, opt.Autor, zeile.Aktion == AktionNeu))
if !opt.Preview && (line.Action == ActionCreated || line.Action == ActionOverwritten) {
written, err := s.PutEntry(ctx, writeRequest(projectID, e, opt.Author, line.Action == ActionCreated))
if err != nil {
// Ungültige Angaben (etwa ein zu langer Titel) meldet
// PutEntry vor jedem Schreibzugriff. Sie betreffen nur
@ -187,38 +187,38 @@ func Importiere(ctx context.Context, s *store.Store, projectID string, dateien [
if ctx.Err() != nil {
return b, ctx.Err()
}
zeile.Aktion, zeile.Grund = AktionFehler, err.Error()
line.Action, line.Reason = ActionFailed, err.Error()
} else {
b.Geschrieben = append(b.Geschrieben, geschrieben)
b.Written = append(b.Written, written)
}
}
}
b.Zeilen = append(b.Zeilen, zeile)
switch zeile.Aktion {
case AktionNeu:
b.Neu++
case AktionUeberschrieben:
b.Ueberschrieben++
case AktionUnveraendert:
b.Unveraendert++
case AktionUebersprungen:
b.Uebersprungen++
case AktionFehler:
b.Fehler++
b.Rows = append(b.Rows, line)
switch line.Action {
case ActionCreated:
b.Created++
case ActionOverwritten:
b.Overwritten++
case ActionUnchanged:
b.Unchanged++
case ActionSkipped:
b.Skipped++
case ActionFailed:
b.Failed++
}
}
return b, nil
}
// schreibauftrag macht aus einer ausgewerteten Datei einen Schreibvorgang.
// writeRequest macht aus einer ausgewerteten Datei einen Schreibvorgang.
//
// Der Autor hängt davon ab, was passiert. Beim Anlegen bleibt der aus der
// Datei, denn eine Wiederherstellung soll zeigen, wer den Eintrag
// ursprünglich geschrieben hat. Beim Überschreiben steht der importierende
// Zugang da: Diese Fassung hat er erzeugt, und so steht es auch im Verlauf.
func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.EntryWrite {
func writeRequest(projectID string, e Entry, author string, isNew bool) store.EntryWrite {
w := store.EntryWrite{
ProjectID: projectID,
Slug: e.Slug,
@ -236,19 +236,19 @@ func schreibauftrag(projectID string, e Eintrag, autor string, neu bool) store.E
if w.Source == "" {
w.Source = store.SourceHuman
}
if w.Author == "" || !neu {
w.Author = autor
if w.Author == "" || !isNew {
w.Author = author
}
return w
}
// gleicherInhalt sagt, ob eine Datei nichts am vorhandenen Eintrag ändern
// sameContent sagt, ob eine Datei nichts am vorhandenen Eintrag ändern
// würde. Verglichen wird, was ein Mensch als Inhalt ansieht: Titel, Text,
// Schlagworte, Prüfvermerk. Autor und Zeitstempel zählen nicht. Sonst wäre
// nach einem Export und Re-Import nie etwas „unverändert“.
func gleicherInhalt(alt store.Entry, neu Eintrag) bool {
return alt.Title == neu.Title &&
alt.Content == neu.Content &&
alt.Curated == neu.Curated &&
slices.Equal(alt.Tags, store.NormalizeTags(neu.Tags))
func sameContent(old store.Entry, newer Entry) bool {
return old.Title == newer.Title &&
old.Content == newer.Content &&
old.Curated == newer.Curated &&
slices.Equal(old.Tags, store.NormalizeTags(newer.Tags))
}

View file

@ -16,7 +16,7 @@ import (
)
// testStore öffnet eine frische Datenbank mit einem Projekt.
func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) {
func testStore(t *testing.T, projectSlug string) (*store.Store, store.Project) {
t.Helper()
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@ -24,48 +24,48 @@ func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) {
}
t.Cleanup(func() { s.Close() })
p, err := s.CreateProject(context.Background(), store.NewID(), projektSlug, "Projekt "+projektSlug, "")
p, err := s.CreateProject(context.Background(), store.NewID(), projectSlug, "Projekt "+projectSlug, "")
if err != nil {
t.Fatal(err)
}
return s, p
}
// packe baut ein ZIP-Archiv aus Pfad → Inhalt.
func packe(t *testing.T, dateien map[string]string) []byte {
// pack baut ein ZIP-Archiv aus Pfad → Inhalt.
func pack(t *testing.T, files map[string]string) []byte {
t.Helper()
var puffer bytes.Buffer
w := zip.NewWriter(&puffer)
for pfad, inhalt := range dateien {
f, err := w.Create(pfad)
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for filePath, content := range files {
f, err := w.Create(filePath)
if err != nil {
t.Fatal(err)
}
f.Write([]byte(inhalt))
f.Write([]byte(content))
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return puffer.Bytes()
return buf.Bytes()
}
// packeOrdner packt alle Dateien eines Ordners, so wie ein Mensch den
// packDir packt alle Dateien eines Ordners, so wie ein Mensch den
// Exportordner eines Projekts zippen würde.
func packeOrdner(t *testing.T, ordner string) []byte {
func packDir(t *testing.T, dir string) []byte {
t.Helper()
dateien := map[string]string{}
eintraege, err := os.ReadDir(ordner)
files := map[string]string{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range eintraege {
inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name()))
for _, e := range entries {
content, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
dateien["projekt/"+e.Name()] = string(inhalt)
files["projekt/"+e.Name()] = string(content)
}
return packe(t, dateien)
return pack(t, files)
}
// TestRoundTripFromExport ist der wichtigste Test: Was der Export schreibt,
@ -73,49 +73,49 @@ func packeOrdner(t *testing.T, ordner string) []byte {
// Autor, Prüfvermerk und Änderungsdatum.
func TestRoundTripFromExport(t *testing.T) {
ctx := context.Background()
quelle, qp := testStore(t, "quelle")
source, qp := testStore(t, "quelle")
alt := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC)
old := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC)
original := []store.EntryWrite{
{Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]",
Tags: []string{"ops", "release"}, Source: store.SourceAgent, Author: "agent-1", Curated: true,
CreatedAt: alt, UpdatedAt: alt},
CreatedAt: old, UpdatedAt: old},
{Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile",
Source: store.SourceHuman, Author: "anna"},
}
for _, w := range original {
w.ProjectID = qp.ID
if _, err := quelle.PutEntry(ctx, w); err != nil {
if _, err := source.PutEntry(ctx, w); err != nil {
t.Fatal(err)
}
}
exportOrdner := t.TempDir()
exportDir := t.TempDir()
qp.WorkspaceID = "ws-test"
if _, err := export.New(exportOrdner).ExportProject(ctx, quelle, qp); err != nil {
if _, err := export.New(exportDir).ExportProject(ctx, source, qp); err != nil {
t.Fatal(err)
}
dateien, err := LeseZip(packeOrdner(t, filepath.Join(exportOrdner, "ws-test", "quelle")))
files, err := ReadZip(packDir(t, filepath.Join(exportDir, "ws-test", "quelle")))
if err != nil {
t.Fatal(err)
}
if len(dateien) != 2 {
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien))
if len(files) != 2 {
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(files))
}
ziel, zp := testStore(t, "ziel")
bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"})
target, zp := testStore(t, "ziel")
report, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if bericht.Neu != 2 || bericht.Fehler != 0 {
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen)
if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
}
for _, w := range original {
a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug)
b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug)
a, _ := source.EntryBySlug(ctx, qp.ID, w.Slug)
b, err := target.EntryBySlug(ctx, zp.ID, w.Slug)
if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err)
}
@ -129,62 +129,62 @@ func TestRoundTripFromExport(t *testing.T) {
}
// Die Verweise sind wieder da, rollback wird von deployment verlinkt.
rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback")
if len(rueck) != 1 || rueck[0].Slug != "deployment" {
t.Errorf("Rückverweise nach dem Import: %+v", rueck)
back, _ := target.Backlinks(ctx, zp.ID, "rollback")
if len(back) != 1 || back[0].Slug != "deployment" {
t.Errorf("Rückverweise nach dem Import: %+v", back)
}
// Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen.
nochmal, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "importeur"})
again, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeOverwrite, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if nochmal.Unveraendert != 2 {
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung())
if again.Unchanged != 2 {
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", again.Summary())
}
}
// TestRoundTripFromDownload: Das ZIP aus dem Download (export.SchreibeZIP)
// TestRoundTripFromDownload: Das ZIP aus dem Download (export.WriteZIP)
// muss ohne Umweg wieder in den Import passen — genau dafür ist es da.
func TestRoundTripFromDownload(t *testing.T) {
ctx := context.Background()
quelle, qp := testStore(t, "quelle")
source, qp := testStore(t, "quelle")
for _, w := range []store.EntryWrite{
{Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"},
{Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"},
} {
w.ProjectID = qp.ID
if _, err := quelle.PutEntry(ctx, w); err != nil {
if _, err := source.PutEntry(ctx, w); err != nil {
t.Fatal(err)
}
}
dateien, err := export.ProjektDateien(ctx, quelle, qp)
files, err := export.ProjectFiles(ctx, source, qp)
if err != nil {
t.Fatal(err)
}
var zip bytes.Buffer
if err := export.SchreibeZIP(&zip, dateien); err != nil {
if err := export.WriteZIP(&zip, files); err != nil {
t.Fatal(err)
}
gelesen, err := LeseZip(zip.Bytes())
read, err := ReadZip(zip.Bytes())
if err != nil {
t.Fatal(err)
}
ziel, zp := testStore(t, "ziel")
bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, Optionen{Modus: ModusUeberspringen, Autor: "importeur"})
target, zp := testStore(t, "ziel")
report, err := Import(ctx, target, zp.ID, read, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if bericht.Neu != 2 || bericht.Fehler != 0 {
t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen)
if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
}
for _, slug := range []string{"deployment", "rollback"} {
a, _ := quelle.EntryBySlug(ctx, qp.ID, slug)
b, err := ziel.EntryBySlug(ctx, zp.ID, slug)
a, _ := source.EntryBySlug(ctx, qp.ID, slug)
b, err := target.EntryBySlug(ctx, zp.ID, slug)
if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
}
@ -205,36 +205,36 @@ func TestConflictModes(t *testing.T) {
t.Fatal(err)
}
dateien := []Datei{
{Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")},
{Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")},
files := []File{
{Path: "vorhanden.md", Content: []byte("# Vorhanden\n\nneu")},
{Path: "frisch.md", Content: []byte("# Frisch\n\nText")},
}
// Vorschau schreibt nichts.
vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"})
// Preview schreibt nichts.
preview, err := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Preview: true, Author: "imp"})
if err != nil {
t.Fatal(err)
}
if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 {
t.Errorf("Vorschau: %s", vorschau.Zusammenfassung())
if preview.Created != 1 || preview.Overwritten != 1 || len(preview.Written) != 0 {
t.Errorf("Vorschau: %s", preview.Summary())
}
if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil {
t.Error("die Vorschau hat geschrieben")
}
// Überspringen lässt Vorhandenes in Ruhe.
ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"})
if ueber.Neu != 1 || ueber.Uebersprungen != 1 {
t.Errorf("Überspringen: %s", ueber.Zusammenfassung())
over, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip, Author: "imp"})
if over.Created != 1 || over.Skipped != 1 {
t.Errorf("Überspringen: %s", over.Summary())
}
if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" {
t.Error("Überspringen hat überschrieben")
}
// Überschreiben legt eine neue Fassung an, die alte steht im Verlauf.
neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"})
if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 {
t.Errorf("Überschreiben: %s", neu.Zusammenfassung())
newer, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Author: "imp"})
if newer.Overwritten != 1 || newer.Unchanged != 1 {
t.Errorf("Überschreiben: %s", newer.Summary())
}
e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden")
if e.Content != "neu" || e.Version != 2 || e.Author != "imp" {
@ -253,10 +253,10 @@ func TestTrashIsNotRevivedWhenSkipping(t *testing.T) {
t.Fatal(err)
}
dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}}
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") {
t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund)
files := []File{{Path: "weg.md", Content: []byte("# Weg\n\nwieder da")}}
b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Skipped != 1 || !strings.Contains(b.Rows[0].Reason, "Papierkorb") {
t.Errorf("Bericht: %s, Grund %q", b.Summary(), b.Rows[0].Reason)
}
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil {
t.Error("der gelöschte Eintrag wurde zurückgeholt")
@ -267,13 +267,13 @@ func TestDuplicateSlugInArchive(t *testing.T) {
ctx := context.Background()
s, p := testStore(t, "test")
dateien := []Datei{
{Pfad: "ops/deploy.md", Inhalt: []byte("eins")},
{Pfad: "alt/deploy.md", Inhalt: []byte("zwei")},
files := []File{
{Path: "ops/deploy.md", Content: []byte("eins")},
{Path: "alt/deploy.md", Content: []byte("zwei")},
}
b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen})
if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") {
t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen)
b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Created != 1 || b.Failed != 1 || !strings.Contains(b.Rows[1].Reason, "ops/deploy.md") {
t.Errorf("Bericht: %s — %+v", b.Summary(), b.Rows)
}
}
@ -286,17 +286,17 @@ func TestParsePlainMarkdown(t *testing.T) {
t.Errorf("gelesen: %+v", e)
}
ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text"))
noHeading, err := Parse("notiz.md", []byte("Nur Text"))
if err != nil {
t.Fatal(err)
}
if ohneUeberschrift.Title != "notiz" {
t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title)
if noHeading.Title != "notiz" {
t.Errorf("Titel ohne Überschrift: %q", noHeading.Title)
}
}
func TestParseForeignFrontmatter(t *testing.T) {
datei := `---
file := `---
title: 'It''s a title'
date: 2024-01-01
tags:
@ -306,7 +306,7 @@ draft: false
---
Inhalt`
e, err := Parse("post.md", []byte(datei))
e, err := Parse("post.md", []byte(file))
if err != nil {
t.Fatal(err)
}
@ -326,44 +326,44 @@ func TestParseInlineListWithCommaInQuotes(t *testing.T) {
}
func TestParseRejects(t *testing.T) {
faelle := map[string]string{
cases := map[string]string{
"offener Kopf": "---\ntitle: x\nText ohne Ende",
"leer": "---\ntitle: x\n---\n\n",
"ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText",
"nur eine Überschrift": "# Titel\n",
}
for name, inhalt := range faelle {
if _, err := Parse("x.md", []byte(inhalt)); err == nil {
for name, content := range cases {
if _, err := Parse("x.md", []byte(content)); err == nil {
t.Errorf("%s: kein Fehler", name)
}
}
}
func TestLeseZipSkipsAndLimits(t *testing.T) {
archiv := packe(t, map[string]string{
func TestReadZipSkipsAndLimits(t *testing.T) {
archive := pack(t, map[string]string{
"projekt/_index.md": "Übersicht",
"projekt/eintrag.md": "Text",
"projekt/bild.png": "PNG",
"__MACOSX/projekt/._eintrag.md": "Müll",
"projekt/.obsidian/workspace.md": "Einstellungen",
})
dateien, err := LeseZip(archiv)
files, err := ReadZip(archive)
if err != nil {
t.Fatal(err)
}
if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" {
t.Errorf("gelesen: %+v", dateien)
if len(files) != 1 || files[0].Path != "projekt/eintrag.md" {
t.Errorf("gelesen: %+v", files)
}
zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)})
if _, err := LeseZip(zuGross); err == nil {
tooLarge := pack(t, map[string]string{"gross.md": strings.Repeat("x", MaxFileBytes+1)})
if _, err := ReadZip(tooLarge); err == nil {
t.Error("eine zu große Datei wurde angenommen")
}
if _, err := LeseZip([]byte("kein zip")); err == nil {
if _, err := ReadZip([]byte("kein zip")); err == nil {
t.Error("Unsinn wurde als Archiv angenommen")
}
if _, err := LeseZip(packe(t, map[string]string{"bild.png": "x"})); err == nil {
if _, err := ReadZip(pack(t, map[string]string{"bild.png": "x"})); err == nil {
t.Error("ein Archiv ohne Markdown wurde angenommen")
}
}

View file

@ -10,9 +10,9 @@ import (
"munin/internal/store"
)
// Eintrag ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Eintrag struct {
Pfad string // Pfad innerhalb des Archivs, für die Anzeige
// Entry ist eine Markdown-Datei, ausgewertet und bereit zum Schreiben.
type Entry struct {
Path string // Pfad innerhalb des Archivs, für die Anzeige
Slug string
Title string
Content string
@ -42,44 +42,44 @@ type Eintrag struct {
//
// Felder, die Munin nicht kennt, werden ignoriert. So scheitert der Import
// nicht an einem Kopf aus Hugo, Obsidian oder Jekyll.
func Parse(pfad string, inhalt []byte) (Eintrag, error) {
e := Eintrag{Pfad: pfad}
func Parse(filePath string, content []byte) (Entry, error) {
e := Entry{Path: filePath}
// Windows-Zeilenenden und ein Byte-Order-Mark am Anfang würden sonst den
// Kopf unkenntlich machen. Beides kommt bei Dateien aus Editoren unter
// Windows häufig vor.
text := strings.ReplaceAll(string(inhalt), "\r\n", "\n")
text := strings.ReplaceAll(string(content), "\r\n", "\n")
text = strings.TrimPrefix(text, "\uFEFF")
kopf, rumpf, err := trenneKopf(text)
head, body, err := splitFrontmatter(text)
if err != nil {
return e, err
}
for name, wert := range kopf {
for name, value := range head {
switch name {
case "slug":
e.Slug = wert.text
e.Slug = value.text
case "title":
e.Title = wert.text
e.Title = value.text
case "tags":
e.Tags = wert.liste
if len(e.Tags) == 0 && wert.text != "" {
e.Tags = value.list
if len(e.Tags) == 0 && value.text != "" {
// "tags: ops" ohne Klammern: ein einzelnes Schlagwort.
e.Tags = []string{wert.text}
e.Tags = []string{value.text}
}
case "source":
if wert.text == store.SourceHuman || wert.text == store.SourceAgent {
e.Source = wert.text
if value.text == store.SourceHuman || value.text == store.SourceAgent {
e.Source = value.text
}
case "author":
e.Author = wert.text
e.Author = value.text
case "curated":
e.Curated = wert.text == "true"
e.Curated = value.text == "true"
case "created_at":
e.CreatedAt = leseZeit(wert.text)
e.CreatedAt = parseTimestamp(value.text)
case "updated_at":
e.UpdatedAt = leseZeit(wert.text)
e.UpdatedAt = parseTimestamp(value.text)
}
}
@ -88,19 +88,19 @@ func Parse(pfad string, inhalt []byte) (Eintrag, error) {
// wieder weg, sonst stünde sie nach jedem Export und Import einmal mehr
// da. Eine Überschrift, die NICHT dem Titel entspricht, bleibt stehen:
// Dann ist sie Teil des Textes.
rumpf = strings.TrimLeft(rumpf, "\n")
if ueberschrift, rest, ok := ersteUeberschrift(rumpf); ok {
body = strings.TrimLeft(body, "\n")
if heading, rest, ok := firstHeading(body); ok {
switch {
case e.Title == "":
e.Title = ueberschrift
rumpf = rest
case ueberschrift == e.Title:
rumpf = rest
e.Title = heading
body = rest
case heading == e.Title:
body = rest
}
}
e.Content = strings.TrimSpace(rumpf)
e.Content = strings.TrimSpace(body)
name := strings.TrimSuffix(path.Base(pfad), path.Ext(pfad))
name := strings.TrimSuffix(path.Base(filePath), path.Ext(filePath))
if e.Title == "" {
e.Title = name
}
@ -117,20 +117,20 @@ func Parse(pfad string, inhalt []byte) (Eintrag, error) {
return e, nil
}
// ersteUeberschrift erkennt eine Überschrift erster Ebene („# Titel“) in der
// firstHeading erkennt eine Überschrift erster Ebene („# Titel“) in der
// ersten Zeile und gibt sie samt dem Rest des Textes zurück.
func ersteUeberschrift(text string) (ueberschrift, rest string, ok bool) {
zeile, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(zeile, "# ") {
func firstHeading(text string) (heading, rest string, ok bool) {
line, rest, _ := strings.Cut(text, "\n")
if !strings.HasPrefix(line, "# ") {
return "", text, false
}
return strings.TrimSpace(zeile[2:]), rest, true
return strings.TrimSpace(line[2:]), rest, true
}
// leseZeit versteht die Schreibweise des Exports (RFC 3339) und ein bloßes
// parseTimestamp versteht die Schreibweise des Exports (RFC 3339) und ein bloßes
// Datum. Alles andere ergibt den Nullzeitpunkt, also „jetzt“ beim Anlegen.
// Ein unlesbares Datum ist kein Grund, eine ganze Datei abzulehnen.
func leseZeit(s string) time.Time {
func parseTimestamp(s string) time.Time {
for _, format := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(format, s); err == nil {
return t
@ -141,14 +141,14 @@ func leseZeit(s string) time.Time {
// ── Kopf (Frontmatter) ───────────────────────────────────────────────────────
// kopfWert ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
// headValue ist ein Feld aus dem Kopf: entweder ein einzelner Text oder eine
// Liste.
type kopfWert struct {
text string
liste []string
type headValue struct {
text string
list []string
}
// trenneKopf trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei.
// splitFrontmatter trennt einen Kopf zwischen zwei Zeilen „---“ vom Rest der Datei.
// Ohne Kopf ist die ganze Datei der Rumpf.
//
// # Warum kein YAML-Paket?
@ -168,59 +168,59 @@ type kopfWert struct {
// tags:
// - a
// - b
func trenneKopf(text string) (map[string]kopfWert, string, error) {
func splitFrontmatter(text string) (map[string]headValue, string, error) {
if !strings.HasPrefix(text, "---\n") {
return nil, text, nil
}
zeilen := strings.Split(text, "\n")
kopf := make(map[string]kopfWert)
var offeneListe string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
lines := strings.Split(text, "\n")
head := make(map[string]headValue)
var openList string // Name eines Feldes, dessen Liste in den nächsten Zeilen folgt
for i := 1; i < len(zeilen); i++ {
zeile := zeilen[i]
for i := 1; i < len(lines); i++ {
line := lines[i]
if strings.TrimSpace(zeile) == "---" {
return kopf, strings.Join(zeilen[i+1:], "\n"), nil
if strings.TrimSpace(line) == "---" {
return head, strings.Join(lines[i+1:], "\n"), nil
}
// Listenpunkt einer Blockliste, etwa " - ops" unter "tags:".
if eingerueckt := strings.TrimSpace(zeile); offeneListe != "" && strings.HasPrefix(eingerueckt, "- ") {
w := kopf[offeneListe]
w.liste = append(w.liste, einzelwert(strings.TrimSpace(eingerueckt[2:])))
kopf[offeneListe] = w
if indented := strings.TrimSpace(line); openList != "" && strings.HasPrefix(indented, "- ") {
w := head[openList]
w.list = append(w.list, singleValue(strings.TrimSpace(indented[2:])))
head[openList] = w
continue
}
offeneListe = ""
openList = ""
if strings.TrimSpace(zeile) == "" || strings.HasPrefix(strings.TrimSpace(zeile), "#") {
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
name, wert, ok := strings.Cut(zeile, ":")
name, value, ok := strings.Cut(line, ":")
if !ok {
continue // keine Zuweisung, etwas, das dieser kleine Leser nicht kennt
}
name = strings.TrimSpace(name)
wert = strings.TrimSpace(wert)
value = strings.TrimSpace(value)
switch {
case wert == "":
offeneListe = name
kopf[name] = kopfWert{}
case strings.HasPrefix(wert, "["):
kopf[name] = kopfWert{liste: inlineListe(wert)}
case value == "":
openList = name
head[name] = headValue{}
case strings.HasPrefix(value, "["):
head[name] = headValue{list: inlineList(value)}
default:
kopf[name] = kopfWert{text: einzelwert(wert)}
head[name] = headValue{text: singleValue(value)}
}
}
return nil, "", errors.New("der Kopf der Datei (zwischen den Zeilen \"---\") ist nicht abgeschlossen")
}
// einzelwert entfernt Anführungszeichen und löst die Maskierungen auf, die
// singleValue entfernt Anführungszeichen und löst die Maskierungen auf, die
// der Export schreibt (siehe export.yamlWert).
func einzelwert(s string) string {
func singleValue(s string) string {
switch {
case len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"':
return strings.NewReplacer(`\\`, `\`, `\"`, `"`, `\n`, "\n", `\t`, "\t").Replace(s[1 : len(s)-1])
@ -231,44 +231,44 @@ func einzelwert(s string) string {
return s
}
// inlineListe zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
// inlineList zerlegt „[a, "b, c", d]“ in seine Bestandteile. Kommas innerhalb
// von Anführungszeichen trennen nicht.
func inlineListe(s string) []string {
func inlineList(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
var teile []string
var aktuell strings.Builder
var inZeichen byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
var parts []string
var current strings.Builder
var inChars byte // 0 = außerhalb, sonst das öffnende Anführungszeichen
fertig := func() {
if t := strings.TrimSpace(aktuell.String()); t != "" {
teile = append(teile, einzelwert(t))
done := func() {
if t := strings.TrimSpace(current.String()); t != "" {
parts = append(parts, singleValue(t))
}
aktuell.Reset()
current.Reset()
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inZeichen == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, einzelwert löst es auf.
aktuell.WriteByte(c)
case inChars == '"' && c == '\\' && i+1 < len(s):
// Maskiertes Zeichen: beide Bytes übernehmen, singleValue löst es auf.
current.WriteByte(c)
i++
aktuell.WriteByte(s[i])
case inZeichen != 0 && c == inZeichen:
inZeichen = 0
aktuell.WriteByte(c)
case inZeichen == 0 && (c == '"' || c == '\''):
inZeichen = c
aktuell.WriteByte(c)
case inZeichen == 0 && c == ',':
fertig()
current.WriteByte(s[i])
case inChars != 0 && c == inChars:
inChars = 0
current.WriteByte(c)
case inChars == 0 && (c == '"' || c == '\''):
inChars = c
current.WriteByte(c)
case inChars == 0 && c == ',':
done()
default:
aktuell.WriteByte(c)
current.WriteByte(c)
}
}
fertig()
return teile
done()
return parts
}

View file

@ -10,7 +10,7 @@ import (
"munin/internal/store"
)
// TestRundreisePostgres prüft Export und Import gegen PostgreSQL: Ein Projekt
// TestRoundTripPostgres prüft Export und Import gegen PostgreSQL: Ein Projekt
// wird als ZIP exportiert (derselbe Weg wie „als ZIP herunterladen“) und in
// einen zweiten, leeren Workspace importiert. Danach müssen dieselben Einträge
// mit demselben Inhalt dastehen.
@ -18,7 +18,7 @@ import (
// Läuft nur mit MUNIN_TEST_PG (siehe internal/store/postgres_test.go). Export
// und Import lesen und schreiben ausschließlich über den Store, sind also
// nicht an SQLite gebunden — dieser Test belegt das.
func TestRundreisePostgres(t *testing.T) {
func TestRoundTripPostgres(t *testing.T) {
url := os.Getenv("MUNIN_TEST_PG")
if url == "" {
t.Skip("MUNIN_TEST_PG ist nicht gesetzt — PostgreSQL-Test übersprungen")
@ -32,7 +32,7 @@ func TestRundreisePostgres(t *testing.T) {
t.Cleanup(func() { sp.Close() })
// Zwei frische Workspace-Schemas, nach dem Test wieder weg.
oeffne := func() *store.Store {
open := func() *store.Store {
id := store.NewID()
s, err := sp.OpenWorkspace(id)
if err != nil {
@ -44,11 +44,11 @@ func TestRundreisePostgres(t *testing.T) {
})
return s
}
quelle, ziel := oeffne(), oeffne()
source, target := open(), open()
// Quelle: ein Projekt mit Schlagworten, Verweis, Umlauten und einem
// kuratierten Eintrag.
projekt, err := quelle.CreateProject(ctx, "ws", "plattform", "Plattform", "")
project, err := source.CreateProject(ctx, "ws", "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
@ -58,50 +58,50 @@ func TestRundreisePostgres(t *testing.T) {
{Slug: "vpn", Title: "VPN-Zugang", Content: "Zugang über vpn.intern — Änderungen bei der IT.",
Tags: []string{"ops", "zugang"}, Source: store.SourceAgent},
} {
e.ProjectID = projekt.ID
if _, err := quelle.PutEntry(ctx, e); err != nil {
e.ProjectID = project.ID
if _, err := source.PutEntry(ctx, e); err != nil {
t.Fatal(err)
}
}
// Export als ZIP und wieder auspacken — wie Download und Upload.
dateien, err := export.ProjektDateien(ctx, quelle, projekt)
files, err := export.ProjectFiles(ctx, source, project)
if err != nil {
t.Fatal(err)
}
var zip bytes.Buffer
if err := export.SchreibeZIP(&zip, dateien); err != nil {
if err := export.WriteZIP(&zip, files); err != nil {
t.Fatal(err)
}
gelesen, err := LeseZip(zip.Bytes())
read, err := ReadZip(zip.Bytes())
if err != nil {
t.Fatal(err)
}
zielProjekt, err := ziel.CreateProject(ctx, "ws", "plattform", "Plattform", "")
targetProject, err := target.CreateProject(ctx, "ws", "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
bericht, err := Importiere(ctx, ziel, zielProjekt.ID, gelesen, Optionen{Modus: ModusUeberspringen})
report, err := Import(ctx, target, targetProject.ID, read, Options{Mode: ModeSkip})
if err != nil {
t.Fatal(err)
}
if bericht.Neu != 2 || bericht.Fehler != 0 {
t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", bericht.Neu, bericht.Fehler, bericht.Zeilen)
if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Import: %d neu, %d Fehler, erwartet 2 neu und 0 Fehler: %+v", report.Created, report.Failed, report.Rows)
}
for _, slug := range []string{"deployment", "vpn"} {
vorher, err := quelle.EntryBySlug(ctx, projekt.ID, slug)
before, err := source.EntryBySlug(ctx, project.ID, slug)
if err != nil {
t.Fatal(err)
}
nachher, err := ziel.EntryBySlug(ctx, zielProjekt.ID, slug)
after, err := target.EntryBySlug(ctx, targetProject.ID, slug)
if err != nil {
t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
}
if nachher.Title != vorher.Title || nachher.Content != vorher.Content ||
nachher.Curated != vorher.Curated || len(nachher.Tags) != len(vorher.Tags) {
t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, vorher, nachher)
if after.Title != before.Title || after.Content != before.Content ||
after.Curated != before.Curated || len(after.Tags) != len(before.Tags) {
t.Errorf("%s unterscheidet sich:\nvorher: %+v\nnachher: %+v", slug, before, after)
}
}
}

View file

@ -1,6 +1,6 @@
// Package markdown ist die eine Stelle, an der Munin Markdown in HTML wandelt —
// für Einträge (internal/entry) genauso wie für die eingebaute Hilfe
// (internal/hilfe).
// (internal/help).
//
// # Warum ein eigenes Paket?
//
@ -34,14 +34,14 @@ import (
"munin/internal/store"
)
// neuerRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall
// newRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall
// gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als
// Parameter dazu.
//
// Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs"
// (variadische Funktion) — man kann neuerRenderer() also auch ganz ohne
// (variadische Funktion) — man kann newRenderer() also auch ganz ohne
// Zusatzoptionen aufrufen.
func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown {
func newRenderer(extra ...parser.Option) goldmark.Markdown {
return goldmark.New(
// GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen:
// Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und
@ -49,7 +49,7 @@ func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown {
// Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was
// Agenten ohnehin schreiben.
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(zusatz...),
goldmark.WithParserOptions(extra...),
)
}
@ -58,48 +58,48 @@ func neuerRenderer(zusatz ...parser.Option) goldmark.Markdown {
// Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist
// damit auch bei gleichzeitigen Anfragen sicher.
var (
// ohneAnker ist für Einträge. Überschriften bekommen hier bewusst KEINE
// withoutAnchors ist für Einträge. Überschriften bekommen hier bewusst KEINE
// id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine
// Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id
// der Seite selbst zusammenstoßen, auf die sich das JavaScript verlässt.
ohneAnker = neuerRenderer()
withoutAnchors = newRenderer()
// mitAnker ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem
// withAnchors ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem
// Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links
// aus der Oberfläche direkt auf einen Abschnitt zeigen können
// (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre
// Überschriften sind also bekannt und unbedenklich.
mitAnker = neuerRenderer(parser.WithAutoHeadingID())
withAnchors = newRenderer(parser.WithAutoHeadingID())
)
// Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge.
func Render(quelle string) (string, error) {
return umwandeln(ohneAnker, quelle)
func Render(source string) (string, error) {
return convert(withoutAnchors, source)
}
// RenderMitAnkern wandelt Markdown in HTML und gibt jeder Überschrift eine id.
// RenderWithAnchors wandelt Markdown in HTML und gibt jeder Überschrift eine id.
// Für die Hilfe.
func RenderMitAnkern(quelle string) (string, error) {
func RenderWithAnchors(source string) (string, error) {
// Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche
// ids schon vergeben sind — und das darf nicht in die nächste Seite
// hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht).
kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}}))
return umwandeln(mitAnker, quelle, parser.WithContext(kontext))
context := parser.NewContext(parser.WithIDs(&anchorIDs{assigned: map[string]bool{}}))
return convert(withAnchors, source, parser.WithContext(context))
}
// umwandeln ist der gemeinsame Teil beider Funktionen.
func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.ParseOption) (string, error) {
// convert ist der gemeinsame Teil beider Funktionen.
func convert(renderer goldmark.Markdown, source string, options ...parser.ParseOption) (string, error) {
// bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis
// schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen
// Puffer füllt und nicht eine Kopie davon.
var puffer bytes.Buffer
if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil {
var buf bytes.Buffer
if err := renderer.Convert([]byte(source), &buf, options...); err != nil {
return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err)
}
return puffer.String(), nil
return buf.String(), nil
}
// ankerIDs erzeugt die id-Attribute der Überschriften in der Hilfe.
// anchorIDs erzeugt die id-Attribute der Überschriften in der Hilfe.
//
// # Warum nicht goldmarks eingebaute Anker?
//
@ -115,34 +115,34 @@ func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...parser.Par
// goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden:
// Generate und Put. In Go muss man ein Interface nicht ausdrücklich
// "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen
// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &ankerIDs{…}
// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &anchorIDs{…}
// an parser.WithIDs übergeben wird.
type ankerIDs struct {
vergeben map[string]bool
type anchorIDs struct {
assigned map[string]bool
}
// Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe
// Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte
// ein "-2" — ids müssen auf einer Seite eindeutig sein.
func (a *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte {
basis := store.Slugify(string(text))
if basis == "" {
func (a *anchorIDs) Generate(text []byte, _ ast.NodeKind) []byte {
base := store.Slugify(string(text))
if base == "" {
// Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben
// ein neutraler Name, eindeutig gemacht wie jeder andere.
basis = "abschnitt"
base = "abschnitt"
}
id := basis
for nummer := 1; a.vergeben[id]; nummer++ {
id = fmt.Sprintf("%s-%d", basis, nummer)
id := base
for number := 1; a.assigned[id]; number++ {
id = fmt.Sprintf("%s-%d", base, number)
}
a.vergeben[id] = true
a.assigned[id] = true
return []byte(id)
}
// Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch
// einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit
// nicht, goldmark verlangt die Methode aber.)
func (a *ankerIDs) Put(id []byte) {
a.vergeben[string(id)] = true
func (a *anchorIDs) Put(id []byte) {
a.assigned[string(id)] = true
}

View file

@ -5,11 +5,11 @@ import (
"testing"
)
// TestGFMErweiterungen: Tabellen, Durchstreichen, Checklisten und automatische
// TestGFMExtensions: Tabellen, Durchstreichen, Checklisten und automatische
// Links funktionieren — in Einträgen genauso wie in der Hilfe (AUSBAUSTUFEN.md 5.3).
func TestGFMErweiterungen(t *testing.T) {
faelle := []struct {
name, quelle, erwartet string
func TestGFMExtensions(t *testing.T) {
cases := []struct {
name, source, want string
}{
{"Tabelle", "| a | b |\n|---|---|\n| 1 | 2 |", "<table>"},
{"Durchstreichen", "~~veraltet~~", "<del>veraltet</del>"},
@ -21,50 +21,50 @@ func TestGFMErweiterungen(t *testing.T) {
// Beide Renderer prüfen: Sie sollen sich nur in den Ankern unterscheiden.
renderer := map[string]func(string) (string, error){
"Render": Render,
"RenderMitAnkern": RenderMitAnkern,
"RenderMitAnkern": RenderWithAnchors,
}
for rName, render := range renderer {
for _, f := range faelle {
html, err := render(f.quelle)
for _, f := range cases {
html, err := render(f.source)
if err != nil {
t.Fatalf("%s/%s: %v", rName, f.name, err)
}
if !strings.Contains(html, f.erwartet) {
t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.erwartet, html)
if !strings.Contains(html, f.want) {
t.Errorf("%s/%s: %q fehlt in %q", rName, f.name, f.want, html)
}
}
}
}
// TestKeinRohHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt
// TestNoRawHTML ist der wichtigste Test dieses Pakets: Die Weboberfläche setzt
// das Ergebnis ohne weiteres Escaping in die Seite. Das geht nur, solange hier
// weder Skripte noch gefährliche Link-Ziele durchkommen — auch nicht mit den
// GFM-Erweiterungen.
func TestKeinRohHTML(t *testing.T) {
boese := []string{
func TestNoRawHTML(t *testing.T) {
evil := []string{
"<script>alert(1)</script>",
`<img src=x onerror="alert(1)">`,
"[klick](javascript:alert(1))",
"| a |\n|---|\n| <script>alert(1)</script> |",
"- [ ] <script>alert(1)</script>",
}
for _, quelle := range boese {
for _, render := range []func(string) (string, error){Render, RenderMitAnkern} {
html, _ := render(quelle)
for _, source := range evil {
for _, render := range []func(string) (string, error){Render, RenderWithAnchors} {
html, _ := render(source)
if strings.Contains(html, "<script") || strings.Contains(html, "onerror") || strings.Contains(html, "javascript:") {
t.Errorf("gefährlicher Inhalt durchgelassen: %q → %q", quelle, html)
t.Errorf("gefährlicher Inhalt durchgelassen: %q → %q", source, html)
}
}
}
}
// TestAnker: Nur die Hilfe bekommt ids an den Überschriften, mit gefalteten
// TestAnchor: Nur die Hilfe bekommt ids an den Überschriften, mit gefalteten
// Umlauten und eindeutig je Seite.
func TestAnker(t *testing.T) {
quelle := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???"
func TestAnchor(t *testing.T) {
source := "## Zugänge verwalten\n\n## Protokoll\n\n## Protokoll\n\n## ???"
html, err := RenderMitAnkern(quelle)
html, err := RenderWithAnchors(source)
if err != nil {
t.Fatal(err)
}
@ -76,15 +76,15 @@ func TestAnker(t *testing.T) {
// Ein zweiter Aufruf fängt wieder von vorn an — die vergebenen ids der
// ersten Seite dürfen nicht hinüberwirken.
nochmal, _ := RenderMitAnkern("## Protokoll")
if !strings.Contains(nochmal, `id="protokoll"`) {
t.Errorf("zweite Seite bekommt nicht die einfache id: %q", nochmal)
again, _ := RenderWithAnchors("## Protokoll")
if !strings.Contains(again, `id="protokoll"`) {
t.Errorf("zweite Seite bekommt nicht die einfache id: %q", again)
}
// Einträge: keine ids, damit Eintragstext nicht mit ids der Seite
// kollidieren kann.
eintrag, _ := Render(quelle)
if strings.Contains(eintrag, "id=") {
t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", eintrag)
entry, _ := Render(source)
if strings.Contains(entry, "id=") {
t.Errorf("Eintrag hat Überschrift-ids bekommen: %q", entry)
}
}

View file

@ -14,24 +14,24 @@ import (
"munin/internal/workspace"
)
// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
// connect öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
//
// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf
// Anders als call meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf
// darf nur aus der Goroutine des Tests selbst kommen, nicht aus den
// nebenläufigen Agenten unten.
func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) {
func connect(h *Handler, ws *workspace.Workspace, project store.Project, name string) (*mcp.ClientSession, func(), error) {
sess := web.Session{
Subject: "key:" + ws.ID + ":" + name,
WorkspaceID: ws.ID,
Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)},
Project: projekt,
Key: store.APIKey{ID: name, Name: name, ProjectID: project.ID, Role: string(auth.RoleWrite)},
Project: project,
Role: auth.RoleWrite,
}
ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
serverSession, err := h.serverFor(project).Connect(ctx, serverTransport, nil)
if err != nil {
return nil, nil, err
}
@ -41,14 +41,14 @@ func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name s
serverSession.Close()
return nil, nil, err
}
schliessen := func() {
closeIt := func() {
clientSession.Close()
serverSession.Close()
}
return clientSession, schliessen, nil
return clientSession, closeIt, nil
}
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex
// TestConcurrentWritesKeepSearchIndexCurrent prüft, dass der Suchindex
// nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der
// Datenbank kennt.
//
@ -60,62 +60,62 @@ func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name s
// Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende
// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden
// sein.
func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
h, ws, projekt := aufbauMitBudget(t, 100000)
func TestConcurrentWritesKeepSearchIndexCurrent(t *testing.T) {
h, ws, project := setupWithBudget(t, 100000)
ctx := context.Background()
// Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der
// Test würde nichts prüfen (siehe Registry.Update).
idx, err := ws.Search.For(ctx, projekt.ID)
idx, err := ws.Search.For(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
const runden, agenten, jeAgent = 20, 8, 10
const rounds, agents, perAgent = 20, 8, 10
for runde := 0; runde < runden; runde++ {
slug := fmt.Sprintf("umkaempft-%02d", runde)
for round := 0; round < rounds; round++ {
slug := fmt.Sprintf("umkaempft-%02d", round)
var wg sync.WaitGroup
var mu sync.Mutex
var fehler []error
var errs []error
for a := 0; a < agenten; a++ {
for a := 0; a < agents; a++ {
wg.Add(1)
go func(a int) {
defer wg.Done()
melde := func(err error) {
report := func(err error) {
mu.Lock()
fehler = append(fehler, err)
errs = append(errs, err)
mu.Unlock()
}
client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a))
client, closeIt, err := connect(h, ws, project, fmt.Sprintf("agent-%d", a))
if err != nil {
melde(err)
report(err)
return
}
defer schliessen()
defer closeIt()
for i := 0; i < jeAgent; i++ {
for i := 0; i < perAgent; i++ {
// Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
word := fmt.Sprintf("marke%sx%s", letters(round*1000+a*100+i), "ende")
res, err := client.CallTool(ctx, &mcp.CallToolParams{
Name: "remember",
// force: Jede Runde legt unter neuem Bezeichner mit
// demselben Titel an. Die Dublettenprüfung würde das
// zu Recht anhalten — hier geht es aber ums
// gleichzeitige Schreiben, nicht um Dubletten.
Arguments: map[string]any{
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort, "force": true,
"slug": slug, "title": "Umkämpft", "content": "Stand " + word, "force": true,
},
})
if err != nil {
melde(err)
report(err)
return
}
if erg.IsError {
melde(fmt.Errorf("remember: %s", text(erg)))
if res.IsError {
report(fmt.Errorf("remember: %s", text(res)))
return
}
}
@ -123,37 +123,37 @@ func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
}
wg.Wait()
if len(fehler) > 0 {
t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0])
if len(errs) > 0 {
t.Fatalf("Runde %d: %d Fehler, erster: %v", round, len(errs), errs[0])
}
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug)
e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if err != nil {
t.Fatal(err)
}
if e.Version != agenten*jeAgent {
if e.Version != agents*perAgent {
t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren",
runde, e.Version, agenten*jeAgent)
round, e.Version, agents*perAgent)
}
// Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
letztesWort := e.Content[len("Stand "):]
gefunden := false
for _, r := range idx.Search(letztesWort, 5) {
lastWord := e.Content[len("Stand "):]
found := false
for _, r := range idx.Search(lastWord, 5) {
if r.DocID == slug {
gefunden = true
found = true
}
}
if !gefunden {
if !found {
t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+
"er hält einen älteren Stand", runde, e.Version, letztesWort)
"er hält einen älteren Stand", round, e.Version, lastWord)
}
}
}
// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9.
// letters schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9.
// So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern.
func buchstaben(n int) string {
func letters(n int) string {
s := fmt.Sprintf("%06d", n)
b := []byte(s)
for i := range b {

View file

@ -8,7 +8,7 @@ package mcpserver
// still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und
// kein Mensch bekam es mit.
//
// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (dubletten.go):
// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (duplicates.go):
//
// 1. remember auf einen kuratierten Eintrag schreibt NICHT, sondern meldet
// judgment_required mit einer Erklärung.
@ -37,9 +37,9 @@ import (
"munin/internal/workspace"
)
// istKuratiert prüft, ob unter slug ein kuratierter Eintrag liegt.
// isCurated prüft, ob unter slug ein kuratierter Eintrag liegt.
// Gibt es den Eintrag nicht, ist er auch nicht kuratiert.
func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) {
func isCurated(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) {
e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
return false, nil
@ -50,20 +50,20 @@ func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Pr
return e.Curated, nil
}
// kuratiertHinweis ist die Antwort an den Agenten, wenn er einen kuratierten
// curatedHint ist die Antwort an den Agenten, wenn er einen kuratierten
// Eintrag ohne override_curated ändern will. Sie sagt ausdrücklich, was er
// tun soll — wie alle Meldungen an Modelle.
const kuratiertHinweis = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " +
const curatedHint = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " +
"Geprüftes Wissen hat Vorrang vor eigenen Annahmen. Ändere ihn nur, wenn er nachweislich nicht " +
"mehr stimmt (etwa weil du gerade das Gegenteil beobachtet hast). Dann denselben Aufruf mit " +
"\"override_curated\": true und unter \"reason\" einer kurzen Begründung wiederholen. " +
"Die Markierung \"kuratiert\" fällt dabei weg, und ein Mensch prüft die Änderung. " +
"Sonst: nichts ändern, oder die Ergänzung in einem eigenen Eintrag festhalten und mit [[slug]] verweisen."
// begruendungPruefen stellt sicher, dass ein override_curated eine
// checkJustification stellt sicher, dass ein override_curated eine
// Begründung trägt. Ohne sie wüsste der prüfende Mensch nicht, warum der
// geprüfte Stand ersetzt wurde.
func begruendungPruefen(in rememberInput) error {
func checkJustification(in rememberInput) error {
if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" {
return errors.New("override_curated braucht eine Begründung unter \"reason\": " +
"Warum stimmt der kuratierte Stand nicht mehr?")

File diff suppressed because it is too large Load diff

View file

@ -229,9 +229,9 @@ type rememberInput struct {
Force bool `json:"force,omitempty" jsonschema:"Optional: true legt den Eintrag an, obwohl es ähnliche gibt. Nur verwenden, nachdem die gemeldeten candidates geprüft wurden und keiner passt."`
}
// dublettenKandidat ist ein vorhandener Eintrag, der einem neu anzulegenden
// ähnelt (siehe internal/dubletten) — so, wie MCP ihn ausliefert.
type dublettenKandidat struct {
// duplicateCandidate ist ein vorhandener Eintrag, der einem neu anzulegenden
// ähnelt (siehe internal/duplicates) — so, wie MCP ihn ausliefert.
type duplicateCandidate struct {
Slug string `json:"slug"`
Title string `json:"title"`
Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"`
@ -250,8 +250,8 @@ type rememberOutput struct {
Message string `json:"message"`
// Nur gesetzt, wenn NICHT geschrieben wurde, weil es ähnliche Einträge gibt.
JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"`
Candidates []dublettenKandidat `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"`
JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"`
Candidates []duplicateCandidate `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"`
}
type forgetInput struct {

View file

@ -11,7 +11,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/dubletten"
"munin/internal/duplicates"
"munin/internal/entry"
"munin/internal/store"
"munin/internal/web"
@ -108,14 +108,14 @@ func (h *Handler) requireWrite(ctx context.Context) error {
// Abfrage: web.APIAuth hat die Projektzeile für DIESEN Request gerade eben
// frisch aus der Datenbank geholt. Der Wert ist damit aktuell, und der
// Schreibpfad kostet keine zusätzliche Abfrage.
erlaubt, retryAfter, ueberschreitung := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if erlaubt {
allowed, retryAfter, excess := h.budget.Consume(sess.Project.ID, sess.Project.WriteLimitHourly)
if allowed {
return nil
}
// Nur die erste Ablehnung je Fenster vermerken — sonst schriebe eine
// Schleife im Agenten das Protokoll voll, statt die Einträge.
if ueberschreitung == 1 {
if excess == 1 {
log.Printf("Schreibbudget erschöpft: Projekt %q (%s), Zugang %q — weitere Schreibversuche werden bis zum Fensterende abgelehnt",
sess.Project.Slug, sess.Project.ID, sess.Key.Name)
}
@ -174,16 +174,16 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
// Etwas großzügiger suchen als am Ende ausgegeben wird: Die
// Schlagwortfilterung unten entfernt womöglich noch Treffer.
synonyme := begrenzeSynonyme(in.Synonyms)
treffer := idx.Search(mitSynonymen(in.Query, synonyme), limit*3)
gesucht := store.NormalizeTags(in.Tags)
synonyms := limitSynonyms(in.Synonyms)
hits := idx.Search(withSynonyms(in.Query, synonyms), limit*3)
wanted := store.NormalizeTags(in.Tags)
jetzt := time.Now()
veraltet := 0
now := time.Now()
stale := 0
ergebnisse := make([]recallMatch, 0, limit)
for _, t := range treffer {
if len(ergebnisse) >= limit {
results := make([]recallMatch, 0, limit)
for _, t := range hits {
if len(results) >= limit {
break
}
@ -195,24 +195,24 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
return nil, recallOutput{}, err
}
if !hatAlleTags(e.Tags, gesucht) {
if !hasAllTags(e.Tags, wanted) {
continue
}
alt := e.Stale(jetzt)
if alt {
veraltet++
old := e.Stale(now)
if old {
stale++
}
ergebnisse = append(ergebnisse, recallMatch{
results = append(results, recallMatch{
Slug: e.Slug,
Title: e.Title,
Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)),
Snippet: entry.Snippet(e.Content, withSynonyms(in.Query, synonyms)),
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
Stale: alt,
Stale: old,
})
}
@ -221,11 +221,11 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{
ProjectID: project.ID, APIKeyID: sess.Key.ID,
Query: in.Query, Synonyms: synonyme, Results: len(ergebnisse),
Query: in.Query, Synonyms: synonyms, Results: len(results),
})
out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)}
if len(ergebnisse) == 0 {
out := recallOutput{Results: results, Total: len(results)}
if len(results) == 0 {
out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " +
"Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " +
"Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " +
@ -236,9 +236,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
// nicht nach hinten: Ein alter Eintrag kann der einzige zum
// Thema sein. Der Hinweis sorgt dafür, dass das Modell das
// Feld "stale" nicht übersieht.
if veraltet > 0 {
if stale > 0 {
out.Hint += fmt.Sprintf(" %d der Treffer sind seit über einem Jahr unverändert (\"stale\": true) "+
"— vor Verwendung prüfen, ob sie noch stimmen.", veraltet)
"— vor Verwendung prüfen, ob sie noch stimmen.", stale)
}
}
return nil, out, nil
@ -246,7 +246,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
)
}
// mitSynonymen hängt die Synonyme an die Anfrage an, sodass sie in derselben
// withSynonyms hängt die Synonyme an die Anfrage an, sodass sie in derselben
// Suche mitlaufen.
//
// # Warum so einfach?
@ -256,44 +256,44 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
// gefunden; einer, der beide Namen enthält, steht weiter oben. Genau das ist
// gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht.
//
// Die Synonyme sollten vorher durch begrenzeSynonyme gegangen sein.
func mitSynonymen(query string, synonyme []string) string {
if len(synonyme) == 0 {
// Die Synonyme sollten vorher durch limitSynonyms gegangen sein.
func withSynonyms(query string, synonyms []string) string {
if len(synonyms) == 0 {
return query
}
return query + " " + strings.Join(synonyme, " ")
return query + " " + strings.Join(synonyms, " ")
}
// begrenzeSynonyme entfernt leere Angaben und kappt die Liste bei
// limitSynonyms entfernt leere Angaben und kappt die Liste bei
// recallMaxSynonyms.
//
// Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe
// mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon.
func begrenzeSynonyme(synonyme []string) []string {
var sauber []string
for _, s := range synonyme {
func limitSynonyms(synonyms []string) []string {
var clean []string
for _, s := range synonyms {
if s = strings.TrimSpace(s); s != "" {
sauber = append(sauber, s)
clean = append(clean, s)
}
if len(sauber) == recallMaxSynonyms {
if len(clean) == recallMaxSynonyms {
break
}
}
return sauber
return clean
}
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hatAlleTags(vorhanden, gesucht []string) bool {
if len(gesucht) == 0 {
// hasAllTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hasAllTags(existing, wanted []string) bool {
if len(wanted) == 0 {
return true
}
menge := make(map[string]bool, len(vorhanden))
for _, t := range vorhanden {
menge[t] = true
set := make(map[string]bool, len(existing))
for _, t := range existing {
set[t] = true
}
for _, g := range gesucht {
if !menge[g] {
for _, g := range wanted {
if !set[g] {
return false
}
}
@ -323,13 +323,13 @@ func (h *Handler) addGet(server *mcp.Server, project store.Project) {
return nil, getOutput{}, err
}
rueck, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
back, err := ws.Store.Backlinks(ctx, project.ID, in.Slug)
if err != nil {
return nil, getOutput{}, err
}
linkedBy := make([]string, 0, len(rueck))
for _, b := range rueck {
linkedBy := make([]string, 0, len(back))
for _, b := range back {
linkedBy = append(linkedBy, b.Slug)
}
@ -378,7 +378,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
limit = listMaxLimit
}
eintraege, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{
entries, err := ws.Store.ListEntries(ctx, project.ID, store.EntryFilter{
Tag: in.Tag,
Curated: in.Curated,
Limit: limit,
@ -387,7 +387,7 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err
}
gesamt, err := ws.Store.CountEntries(ctx, project.ID)
total, err := ws.Store.CountEntries(ctx, project.ID)
if err != nil {
return nil, listOutput{}, err
}
@ -397,9 +397,9 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
return nil, listOutput{}, err
}
jetzt := time.Now()
items := make([]listItem, 0, len(eintraege))
for _, e := range eintraege {
now := time.Now()
items := make([]listItem, 0, len(entries))
for _, e := range entries {
items = append(items, listItem{
Slug: e.Slug,
Title: e.Title,
@ -407,16 +407,16 @@ func (h *Handler) addList(server *mcp.Server, project store.Project) {
Source: e.Source,
Curated: e.Curated,
Updated: e.UpdatedAt.Format("2006-01-02 15:04"),
Stale: e.Stale(jetzt),
Stale: e.Stale(now),
})
}
tagNamen := make([]string, 0, len(tags))
tagNames := make([]string, 0, len(tags))
for _, t := range tags {
tagNamen = append(tagNamen, t.Tag)
tagNames = append(tagNames, t.Tag)
}
return nil, listOutput{Entries: items, Total: gesamt, Tags: tagNamen}, nil
return nil, listOutput{Entries: items, Total: total, Tags: tagNames}, nil
},
)
}
@ -445,7 +445,7 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
if strings.TrimSpace(in.Content) == "" {
return nil, rememberOutput{}, errors.New("content darf nicht leer sein")
}
if err := begruendungPruefen(in); err != nil {
if err := checkJustification(in); err != nil {
return nil, rememberOutput{}, err
}
@ -459,31 +459,31 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{}, errors.New("slug fehlt und ließ sich nicht aus dem Titel ableiten")
}
titel := strings.TrimSpace(in.Title)
if titel == "" {
titel = entry.TitleFromContent(in.Content)
title := strings.TrimSpace(in.Title)
if title == "" {
title = entry.TitleFromContent(in.Content)
}
// Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner?
// Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe
// internal/dubletten). Kein Fehler: Das Modell soll die Kandidaten als
// internal/duplicates). Kein Fehler: Das Modell soll die Kandidaten als
// Daten lesen und entscheiden, nicht einen Fehlschlag behandeln.
if !in.Force {
gefunden, err := dubletten.Pruefe(ctx, ws, project, slug, titel, in.Content)
found, err := duplicates.Check(ctx, ws, project, slug, title, in.Content)
if err != nil {
return nil, rememberOutput{}, err
}
kandidaten := make([]dublettenKandidat, 0, len(gefunden))
for _, k := range gefunden {
kandidaten = append(kandidaten, dublettenKandidat{
candidates := make([]duplicateCandidate, 0, len(found))
for _, k := range found {
candidates = append(candidates, duplicateCandidate{
Slug: k.Slug, Title: k.Title, Overlap: k.Overlap, MatchedWords: k.MatchedWords,
})
}
if len(kandidaten) > 0 {
if len(candidates) > 0 {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Candidates: kandidaten,
Candidates: candidates,
Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " +
"\"matched_words\" nennt die gemeinsamen Wörter. " +
"Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " +
@ -495,24 +495,24 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
}
// Kuratierter Eintrag? Dann nur mit override_curated (siehe
// kuratiert.go). Wie bei den Dubletten kein Fehler, sondern eine
// curated.go). Wie bei den Dubletten kein Fehler, sondern eine
// Meldung, die das Modell liest und auf die es entscheidet.
warKuratiert, err := istKuratiert(ctx, ws, project, slug)
wasCurated, err := isCurated(ctx, ws, project, slug)
if err != nil {
return nil, rememberOutput{}, err
}
if warKuratiert && !in.OverrideCurated {
if wasCurated && !in.OverrideCurated {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Message: kuratiertHinweis,
Message: curatedHint,
}, nil
}
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
written, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
Title: titel,
Title: title,
Content: in.Content,
Tags: in.Tags,
Source: store.SourceAgent,
@ -529,13 +529,13 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{}, err
}
ws.Search.Refresh(ctx, project.ID, geschrieben.Slug)
ws.Search.Refresh(ctx, project.ID, written.Slug)
// Hat der Agent einen kuratierten Eintrag geändert, steht das samt
// Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste
// „bitte prüfen" (store.CuratedOverrides).
detail := store.EntryWriteDetail(geschrieben)
if warKuratiert {
detail := store.EntryWriteDetail(written)
if wasCurated {
detail = store.CuratedOverrideDetail(in.Reason)
}
@ -552,23 +552,23 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
// (PutEntry vergibt beim Anlegen immer 1) — eine Nachfrage vorher
// könnte bei zwei gleichzeitigen Schreibern danebenliegen, und sie
// sähe einen Eintrag im Papierkorb nicht.
istNeu := geschrieben.Version == 1
isNew := written.Version == 1
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
message := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, written.Version)
switch {
case warKuratiert:
meldung = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+
case wasCurated:
message = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+
"ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.",
slug, geschrieben.Version)
case istNeu:
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
case geschrieben.RestoredFromTrash:
slug, written.Version)
case isNew:
message = fmt.Sprintf("Eintrag %q angelegt.", slug)
case written.RestoredFromTrash:
// Der Bezeichner gehörte einem gelöschten Eintrag. Das Modell
// konnte ihn vorher mit recall/get nicht sehen — es soll aber
// wissen, dass es gerade etwas Gelöschtes wiederbelebt hat.
meldung = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+
message = fmt.Sprintf("Unter %q lag ein gelöschter Eintrag im Papierkorb. Er wurde zurückgeholt "+
"und mit deinem Inhalt überschrieben (Fassung %d); der gelöschte Stand steht im Verlauf. "+
"War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, geschrieben.Version)
"War das nicht beabsichtigt, den Menschen darauf hinweisen.", slug, written.Version)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryRestore, EntrySlug: slug, Source: store.SourceMCP,
@ -578,9 +578,9 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
return nil, rememberOutput{
Slug: slug,
Version: geschrieben.Version,
Created: istNeu,
Message: meldung,
Version: written.Version,
Created: isNew,
Message: message,
}, nil
},
)
@ -652,12 +652,12 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
return nil, relatedOutput{}, err
}
abstand := in.Distance
if abstand <= 0 {
abstand = relatedDefaultDistance
distance := in.Distance
if distance <= 0 {
distance = relatedDefaultDistance
}
if abstand > relatedMaxDistance {
abstand = relatedMaxDistance
if distance > relatedMaxDistance {
distance = relatedMaxDistance
}
limit := in.Limit
@ -668,7 +668,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
limit = relatedMaxLimit
}
verwandte, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, abstand, limit)
related, err := ws.Store.RelatedEntries(ctx, project.ID, in.Slug, distance, limit)
if errors.Is(err, store.ErrNotFound) {
return nil, relatedOutput{}, fmt.Errorf("kein Eintrag mit dem Bezeichner %q — mit \"recall\" nach dem Thema suchen", in.Slug)
}
@ -676,9 +676,9 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
return nil, relatedOutput{}, err
}
jetzt := time.Now()
items := make([]relatedItem, 0, len(verwandte))
for _, v := range verwandte {
now := time.Now()
items := make([]relatedItem, 0, len(related))
for _, v := range related {
items = append(items, relatedItem{
Slug: v.Slug,
Title: v.Title,
@ -686,7 +686,7 @@ func (h *Handler) addRelated(server *mcp.Server, project store.Project) {
Via: v.Via,
Relation: v.Relation,
Updated: v.UpdatedAt.Format("2006-01-02 15:04"),
Stale: store.StaleSince(v.UpdatedAt, jetzt),
Stale: store.StaleSince(v.UpdatedAt, now),
})
}

View file

@ -7,27 +7,27 @@ import (
"testing"
)
// baueGrossenIndex legt einen Index in der Größenordnung eines großen Projekts
// buildLargeIndex legt einen Index in der Größenordnung eines großen Projekts
// an: 10.000 Einträge à 150 Wörter aus einem Wortschatz von 20.000 Wörtern.
//
// Die Wörter sind künstlich ("wort123"), das genügt: Für die Laufzeit zählt,
// wie viele verschiedene Wörter der Index durchgehen muss, nicht was sie heißen.
// Der feste Startwert macht jeden Lauf gleich.
func baueGrossenIndex() *Index {
func buildLargeIndex() *Index {
const (
eintraege = 10_000
woerter = 150
wortschatz = 20_000
entries = 10_000
words = 150
vocabulary = 20_000
)
zufall := rand.New(rand.NewSource(1))
rng := rand.New(rand.NewSource(1))
idx := NewIndex()
var text strings.Builder
for i := 0; i < eintraege; i++ {
for i := 0; i < entries; i++ {
text.Reset()
for j := 0; j < woerter; j++ {
fmt.Fprintf(&text, "wort%d ", zufall.Intn(wortschatz))
for j := 0; j < words; j++ {
fmt.Fprintf(&text, "wort%d ", rng.Intn(vocabulary))
}
idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil)
}
@ -39,7 +39,7 @@ func baueGrossenIndex() *Index {
//
// go test ./internal/search/ -bench . -run '^$'
func BenchmarkSearch(b *testing.B) {
idx := baueGrossenIndex()
idx := buildLargeIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {

View file

@ -225,10 +225,10 @@ func (s *Index) removeUnsafe(docID string) {
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
//
// Neben exakten Treffern findet Search auch Wortanfänge, Wörter mit anderer
// Endung und zusammengesetzte Wörter (siehe varianten.go). Tippfehler
// Endung und zusammengesetzte Wörter (siehe variants.go). Tippfehler
// gleicht Search nicht aus — dafür gibt es SearchFuzzy.
func (s *Index) Search(queryStr string, limit int) []Result {
return s.suche(queryStr, limit, false)
return s.search(queryStr, limit, false)
}
// SearchFuzzy sucht wie Search, findet aber zusätzlich Wörter mit Tippfehlern
@ -240,15 +240,15 @@ func (s *Index) Search(queryStr string, limit int) []Result {
// Tippfehler; bei ihnen brächte die Toleranz vor allem zusätzliche, unsichere
// Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search.
func (s *Index) SearchFuzzy(queryStr string, limit int) []Result {
return s.suche(queryStr, limit, true)
return s.search(queryStr, limit, true)
}
// suche ist die gemeinsame Umsetzung von Search und SearchFuzzy.
func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
// search ist die gemeinsame Umsetzung von Search und SearchFuzzy.
func (s *Index) search(queryStr string, limit int, typo bool) []Result {
s.mu.RLock()
defer s.mu.RUnlock()
queryTokens := ohneStoppwoerter(tokenize(queryStr))
queryTokens := withoutStopwords(tokenize(queryStr))
if len(queryTokens) == 0 || len(s.docs) == 0 {
return nil
}
@ -265,9 +265,9 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
// Je Dokument zählt für dieses Suchwort nur die beste Variante —
// ein exakter Treffer wird also nicht durch denselben Treffer als
// Wortanfang noch einmal aufgewertet.
beste := make(map[string]float64)
best := make(map[string]float64)
for _, v := range s.variantenFuer(term, tippfehler) {
for _, v := range s.variantsFor(term, typo) {
// IDF (Inverse Document Frequency):
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
@ -285,15 +285,15 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
tf := float64(p.freq)
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
wert := v.gewicht * idf * tfNorm
if wert > beste[p.docID] {
beste[p.docID] = wert
value := v.weight * idf * tfNorm
if value > best[p.docID] {
best[p.docID] = value
}
}
}
for docID, wert := range beste {
scores[docID] += wert
for docID, value := range best {
scores[docID] += value
}
}
@ -325,7 +325,7 @@ func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
// Es werden nur Buchstaben und Ziffern behalten, alles andere gilt als Trennzeichen.
// Einzeichen-Tokens werden ignoriert (kaum aussagekräftig).
//
// Umlaute und ß werden dabei vereinheitlicht — siehe faltUmlaut.
// Umlaute und ß werden dabei vereinheitlicht — siehe foldUmlauts.
func tokenize(text string) []string {
text = strings.ToLower(text)
var tokens []string
@ -333,7 +333,7 @@ func tokenize(text string) []string {
for _, r := range text {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
current.WriteString(faltUmlaut(r))
current.WriteString(foldUmlauts(r))
} else {
if current.Len() > 1 {
tokens = append(tokens, current.String())
@ -349,7 +349,7 @@ func tokenize(text string) []string {
return tokens
}
// faltUmlaut bildet Umlaute und ß auf ihre Grundform ab.
// foldUmlauts bildet Umlaute und ß auf ihre Grundform ab.
//
// # Wozu?
//
@ -375,7 +375,7 @@ func tokenize(text string) []string {
//
// Alle anderen Zeichen bleiben unverändert; für lateinische Buchstaben und
// Ziffern kostet die Funktion nur einen Vergleich.
func faltUmlaut(r rune) string {
func foldUmlauts(r rune) string {
switch r {
case 'ä':
return "a"

View file

@ -28,16 +28,16 @@ type Registry struct {
// mu schützt nur die Zuordnung Projekt → Fach und wird immer nur kurz
// gehalten. Die eigentliche Arbeit (Aufbau, Aktualisierung) läuft unter
// der Sperre des jeweiligen Fachs.
mu sync.Mutex
faecher map[string]*fach // Schlüssel: Projekt-ID
mu sync.Mutex
slots map[string]*slot // Schlüssel: Projekt-ID
// nachDemLesen wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen
// afterRead wird nur von Tests gesetzt: Es läuft beim Aufbau zwischen
// dem Lesen der Einträge und dem Befüllen des Index. So lässt sich ein
// langsamer Aufbau nachstellen.
nachDemLesen func(projectID string)
afterRead func(projectID string)
}
// fach hält den Index eines einzelnen Projekts.
// slot hält den Index eines einzelnen Projekts.
//
// # Warum eine Sperre je Projekt?
//
@ -48,7 +48,7 @@ type Registry struct {
//
// Mit einer Sperre je Projekt wartet nur, wer auf genau dieses Projekt
// zugreift. Das muss er auch: Ohne fertigen Index gibt es nichts zu suchen.
type fach struct {
type slot struct {
mu sync.Mutex
idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist
}
@ -62,8 +62,8 @@ type fach struct {
// genau passenden ungeprüften nicht verdrängen.
const curatedBoost = 1.2
// boostFuer gibt den Suchfaktor eines Eintrags zurück.
func boostFuer(e store.Entry) float64 {
// boostFor gibt den Suchfaktor eines Eintrags zurück.
func boostFor(e store.Entry) float64 {
if e.Curated {
return curatedBoost
}
@ -74,31 +74,31 @@ func boostFuer(e store.Entry) float64 {
// Zugriff auf ein Projekt (siehe For).
func NewRegistry(s *store.Store) *Registry {
return &Registry{
store: s,
faecher: make(map[string]*fach),
store: s,
slots: make(map[string]*slot),
}
}
// fachFuer gibt das Fach eines Projekts zurück und legt es bei Bedarf an —
// slotFor gibt das Fach eines Projekts zurück und legt es bei Bedarf an —
// zunächst leer, aufgebaut wird erst in For.
func (r *Registry) fachFuer(projectID string) *fach {
func (r *Registry) slotFor(projectID string) *slot {
r.mu.Lock()
defer r.mu.Unlock()
f, ok := r.faecher[projectID]
f, ok := r.slots[projectID]
if !ok {
f = &fach{}
r.faecher[projectID] = f
f = &slot{}
r.slots[projectID] = f
}
return f
}
// vorhandenesFach gibt das Fach eines Projekts zurück, ohne eines anzulegen.
func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) {
// existingSlot gibt das Fach eines Projekts zurück, ohne eines anzulegen.
func (r *Registry) existingSlot(projectID string) (*slot, bool) {
r.mu.Lock()
defer r.mu.Unlock()
f, ok := r.faecher[projectID]
f, ok := r.slots[projectID]
return f, ok
}
@ -113,7 +113,7 @@ func (r *Registry) vorhandenesFach(projectID string) (*fach, bool) {
// erste auf. Die anderen warten an der Sperre des Fachs und bekommen danach
// den fertigen Index.
func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
f := r.fachFuer(projectID)
f := r.slotFor(projectID)
f.mu.Lock()
defer f.mu.Unlock()
@ -134,17 +134,17 @@ func (r *Registry) For(ctx context.Context, projectID string) (*Index, error) {
// fill liest alle Einträge eines Projekts und nimmt sie in den Index auf.
func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error {
eintraege, err := r.store.AllEntries(ctx, projectID)
entries, err := r.store.AllEntries(ctx, projectID)
if err != nil {
return fmt.Errorf("Suchindex konnte nicht aufgebaut werden: %w", err)
}
if r.nachDemLesen != nil {
r.nachDemLesen(projectID)
if r.afterRead != nil {
r.afterRead(projectID)
}
for _, e := range eintraege {
idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e))
for _, e := range entries {
idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e))
}
return nil
}
@ -170,7 +170,7 @@ func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error
// Reihenfolge die Aufrufe ankommen. Das kostet eine kleine Leseabfrage je
// Schreibvorgang.
func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
f, ok := r.vorhandenesFach(projectID)
f, ok := r.existingSlot(projectID)
if !ok {
return
}
@ -200,7 +200,7 @@ func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err)
f.idx = nil
default:
f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e))
f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFor(e))
}
}
@ -208,5 +208,5 @@ func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
func (r *Registry) Forget(projectID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.faecher, projectID)
delete(r.slots, projectID)
}

View file

@ -9,9 +9,9 @@ import (
"munin/internal/store"
)
// aufbauRegistry legt eine Datenbank mit einem Projekt und eine Registry mit
// setupRegistry legt eine Datenbank mit einem Projekt und eine Registry mit
// bereits aufgebautem Index an.
func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) {
func setupRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Index) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
@ -21,24 +21,24 @@ func aufbauRegistry(t *testing.T) (*Registry, *store.Store, store.Project, *Inde
t.Cleanup(func() { db.Close() })
ctx := context.Background()
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
if err != nil {
t.Fatal(err)
}
r := NewRegistry(db)
// Den Index vorher anlegen — sonst sind Aktualisierungen ein No-op.
idx, err := r.For(ctx, projekt.ID)
idx, err := r.For(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
return r, db, projekt, idx
return r, db, project, idx
}
func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt string) store.Entry {
func write(t *testing.T, db *store.Store, project store.Project, slug, content string) store.Entry {
t.Helper()
e, err := db.PutEntry(context.Background(), store.EntryWrite{
ProjectID: projekt.ID, Slug: slug, Title: "Eintrag", Content: inhalt, Source: store.SourceAgent,
ProjectID: project.ID, Slug: slug, Title: "Eintrag", Content: content, Source: store.SourceAgent,
})
if err != nil {
t.Fatal(err)
@ -46,8 +46,8 @@ func schreibe(t *testing.T, db *store.Store, projekt store.Project, slug, inhalt
return e
}
func findet(idx *Index, wort, slug string) bool {
for _, r := range idx.Search(wort, 10) {
func finds(idx *Index, word, slug string) bool {
for _, r := range idx.Search(word, 10) {
if r.DocID == slug {
return true
}
@ -55,54 +55,54 @@ func findet(idx *Index, wort, slug string) bool {
return false
}
// TestUeberholteAktualisierungHaeltIndexAktuell stellt den Fall nach, dass sich
// TestStaleUpdateKeepsIndexCurrent stellt den Fall nach, dass sich
// zwei Schreiber zwischen Datenbank und Index überholen: Fassung 2 und 3 sind
// gespeichert, aber die Meldung für Fassung 3 kommt beim Index zuerst an.
//
// Unter Last lässt sich das kaum zuverlässig auslösen (siehe
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell im Paket mcpserver), hier
// TestConcurrentWritesKeepSearchIndexCurrent im Paket mcpserver), hier
// wird die Reihenfolge deshalb von Hand vertauscht.
func TestUeberholteAktualisierungHaeltIndexAktuell(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t)
func TestStaleUpdateKeepsIndexCurrent(t *testing.T) {
r, db, project, idx := setupRegistry(t)
schreibe(t, db, projekt, "umkaempft", "Stand wortzwei")
schreibe(t, db, projekt, "umkaempft", "Stand wortdrei")
write(t, db, project, "umkaempft", "Stand wortzwei")
write(t, db, project, "umkaempft", "Stand wortdrei")
r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 3
r.Refresh(context.Background(), projekt.ID, "umkaempft") // Schreiber von Fassung 2, verspätet
r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 3
r.Refresh(context.Background(), project.ID, "umkaempft") // Schreiber von Fassung 2, verspätet
if !findet(idx, "wortdrei", "umkaempft") {
if !finds(idx, "wortdrei", "umkaempft") {
t.Error("der Index kennt den aktuellen Stand (Fassung 3) nicht")
}
if findet(idx, "wortzwei", "umkaempft") {
if finds(idx, "wortzwei", "umkaempft") {
t.Error("der Index hält einen überholten Stand (Fassung 2)")
}
}
// TestVerspaeteteAktualisierungNachLoeschenBleibtWeg: Ein Eintrag wird
// TestLateUpdateAfterDeleteStaysGone: Ein Eintrag wird
// geschrieben und gleich darauf gelöscht. Kommt die Meldung des Schreibers erst
// nach der des Löschens beim Index an, darf der Eintrag nicht wieder auftauchen.
func TestVerspaeteteAktualisierungNachLoeschenBleibtWeg(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t)
func TestLateUpdateAfterDeleteStaysGone(t *testing.T) {
r, db, project, idx := setupRegistry(t)
ctx := context.Background()
schreibe(t, db, projekt, "kurzlebig", "Stand wortgeloescht")
if err := db.MoveEntryToTrash(ctx, projekt.ID, "kurzlebig", "test"); err != nil {
write(t, db, project, "kurzlebig", "Stand wortgeloescht")
if err := db.MoveEntryToTrash(ctx, project.ID, "kurzlebig", "test"); err != nil {
t.Fatal(err)
}
r.Refresh(ctx, projekt.ID, "kurzlebig") // Löschender
r.Refresh(ctx, projekt.ID, "kurzlebig") // Schreiber, verspätet
r.Refresh(ctx, project.ID, "kurzlebig") // Löschender
r.Refresh(ctx, project.ID, "kurzlebig") // Schreiber, verspätet
if findet(idx, "wortgeloescht", "kurzlebig") {
if finds(idx, "wortgeloescht", "kurzlebig") {
t.Error("ein gelöschter Eintrag ist wieder im Suchindex")
}
}
// TestLangsamerAufbauBremstAndereProjekteNicht: Während der Index von Projekt A
// TestSlowBuildDoesNotBlockOtherProjects: Während der Index von Projekt A
// noch aufgebaut wird, muss Projekt B ohne Wartezeit durchkommen. Früher hielt
// der Aufbau eine Sperre für den ganzen Workspace.
func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
func TestSlowBuildDoesNotBlockOtherProjects(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
@ -110,40 +110,40 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
t.Cleanup(func() { db.Close() })
ctx := context.Background()
gross, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "")
big, err := db.CreateProject(ctx, store.NewID(), "gross", "Groß", "")
if err != nil {
t.Fatal(err)
}
klein, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "")
small, err := db.CreateProject(ctx, store.NewID(), "klein", "Klein", "")
if err != nil {
t.Fatal(err)
}
r := NewRegistry(db)
imAufbau := make(chan struct{})
weiter := make(chan struct{})
r.nachDemLesen = func(projectID string) {
if projectID == gross.ID {
close(imAufbau)
<-weiter // Aufbau hängt, bis der Test ihn freigibt
building := make(chan struct{})
release := make(chan struct{})
r.afterRead = func(projectID string) {
if projectID == big.ID {
close(building)
<-release // Aufbau hängt, bis der Test ihn freigibt
}
}
fertig := make(chan error)
done := make(chan error)
go func() {
_, err := r.For(ctx, gross.ID)
fertig <- err
_, err := r.For(ctx, big.ID)
done <- err
}()
<-imAufbau
<-building
// Projekt B, während A noch aufgebaut wird.
klar := make(chan error)
smallDone := make(chan error)
go func() {
_, err := r.For(ctx, klein.ID)
klar <- err
_, err := r.For(ctx, small.ID)
smallDone <- err
}()
select {
case err := <-klar:
case err := <-smallDone:
if err != nil {
t.Fatal(err)
}
@ -151,17 +151,17 @@ func TestLangsamerAufbauBremstAndereProjekteNicht(t *testing.T) {
t.Fatal("Projekt B wartet auf den Aufbau von Projekt A")
}
close(weiter)
if err := <-fertig; err != nil {
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
}
// TestAenderungWaehrendDesAufbausGehtNichtVerloren: Der Aufbau hat die
// TestChangeDuringBuildIsNotLost: Der Aufbau hat die
// Einträge schon gelesen, da wird einer davon geändert. Das Refresh des
// Schreibers muss warten und danach den neuen Stand nachtragen — sonst hielte
// der fertige Index den Stand von vor der Änderung.
func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) {
func TestChangeDuringBuildIsNotLost(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
@ -169,66 +169,66 @@ func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) {
t.Cleanup(func() { db.Close() })
ctx := context.Background()
projekt, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
project, err := db.CreateProject(ctx, store.NewID(), "test", "Test", "")
if err != nil {
t.Fatal(err)
}
schreibe(t, db, projekt, "eintrag", "Stand wortalt")
write(t, db, project, "eintrag", "Stand wortalt")
r := NewRegistry(db)
refreshFertig := make(chan struct{})
r.nachDemLesen = func(string) {
refreshDone := make(chan struct{})
r.afterRead = func(string) {
// Die Einträge sind gelesen, der Index noch nicht befüllt. Jetzt
// schreibt jemand und meldet es, wie es die Handler tun.
schreibe(t, db, projekt, "eintrag", "Stand wortneu")
write(t, db, project, "eintrag", "Stand wortneu")
go func() {
r.Refresh(ctx, projekt.ID, "eintrag")
close(refreshFertig)
r.Refresh(ctx, project.ID, "eintrag")
close(refreshDone)
}()
// Refresh darf nicht durchlaufen, solange der Aufbau läuft.
select {
case <-refreshFertig:
case <-refreshDone:
t.Error("Refresh lief während des Aufbaus durch")
case <-time.After(50 * time.Millisecond):
}
}
idx, err := r.For(ctx, projekt.ID)
idx, err := r.For(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
<-refreshFertig
<-refreshDone
if !findet(idx, "wortneu", "eintrag") {
if !finds(idx, "wortneu", "eintrag") {
t.Error("die Änderung während des Aufbaus fehlt im Index")
}
if findet(idx, "wortalt", "eintrag") {
if finds(idx, "wortalt", "eintrag") {
t.Error("der Index hält den Stand von vor der Änderung")
}
}
// TestRegistryBevorzugtKuratierte prüft, dass die Registry kuratierten
// TestRegistryPrefersCurated prüft, dass die Registry kuratierten
// Einträgen den Suchfaktor mitgibt — beim Aufbau wie beim Nachziehen.
func TestRegistryBevorzugtKuratierte(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t)
func TestRegistryPrefersCurated(t *testing.T) {
r, db, project, idx := setupRegistry(t)
ctx := context.Background()
for _, kuratiert := range []bool{false, true} {
for _, curated := range []bool{false, true} {
slug := "ungeprueft"
if kuratiert {
if curated {
slug = "geprueft"
}
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.",
Source: store.SourceHuman, Curated: kuratiert,
ProjectID: project.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.",
Source: store.SourceHuman, Curated: curated,
}); err != nil {
t.Fatal(err)
}
r.Refresh(ctx, projekt.ID, slug)
r.Refresh(ctx, project.ID, slug)
}
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "geprueft" {
t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", ergebnisIDs(treffer))
hits := idx.Search("deployment", 10)
if len(hits) != 2 || hits[0].DocID != "geprueft" {
t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", resultIDs(hits))
}
}

View file

@ -5,8 +5,8 @@ import (
"testing"
)
// baueIndex legt einen Index mit ein paar Dokumenten an.
func baueIndex() *Index {
// buildIndex legt einen Index mit ein paar Dokumenten an.
func buildIndex() *Index {
idx := NewIndex()
idx.Index("deployment-prozess", "Deployment-Prozess",
"Wir deployen über GitHub Actions auf den Produktivserver.", []string{"ops", "deployment"})
@ -17,20 +17,20 @@ func baueIndex() *Index {
return idx
}
// ergebnisIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
func ergebnisIDs(treffer []Result) []string {
ids := make([]string, len(treffer))
for i, t := range treffer {
// resultIDs gibt die Kennungen der Treffer in Reihenfolge zurück.
func resultIDs(hits []Result) []string {
ids := make([]string, len(hits))
for i, t := range hits {
ids[i] = t.DocID
}
return ids
}
func TestSearchFindsExactTerm(t *testing.T) {
treffer := baueIndex().Search("bastion", 10)
hits := buildIndex().Search("bastion", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v, erwartet nur server-zugang", ergebnisIDs(treffer))
if len(hits) != 1 || hits[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v, erwartet nur server-zugang", resultIDs(hits))
}
}
@ -41,13 +41,13 @@ func TestSearchRanksTitleHigher(t *testing.T) {
idx.Index("im-titel", "Deployment", "Ein Text über etwas ganz anderes.", nil)
idx.Index("im-text", "Irgendwas", "Hier kommt das Wort Deployment einmal vor.", nil)
treffer := idx.Search("deployment", 10)
hits := idx.Search("deployment", 10)
if len(treffer) != 2 {
t.Fatalf("%d Treffer, erwartet 2", len(treffer))
if len(hits) != 2 {
t.Fatalf("%d Treffer, erwartet 2", len(hits))
}
if treffer[0].DocID != "im-titel" {
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", treffer[0].DocID)
if hits[0].DocID != "im-titel" {
t.Errorf("bester Treffer = %q, erwartet den Titel-Treffer", hits[0].DocID)
}
}
@ -57,21 +57,21 @@ func TestSearchRanksTitleHigher(t *testing.T) {
// bei einer KI-Anfrage ein stiller Fehltreffer: Das Modell erfährt schlicht
// nicht, dass es den Eintrag gäbe.
func TestSearchFindsByPrefix(t *testing.T) {
treffer := baueIndex().Search("deploy", 10)
hits := buildIndex().Search("deploy", 10)
if len(treffer) == 0 {
if len(hits) == 0 {
t.Fatal("die Präfix-Suche fand nichts")
}
if treffer[0].DocID != "deployment-prozess" {
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", treffer[0].DocID)
if hits[0].DocID != "deployment-prozess" {
t.Errorf("bester Treffer = %q, erwartet deployment-prozess", hits[0].DocID)
}
}
// TestSearchPrefixNeedsThreeChars prüft, dass sehr kurze Bruchstücke nicht
// greifen — "de" würde sonst auf halbe Wörterbücher passen.
func TestSearchPrefixNeedsThreeChars(t *testing.T) {
if treffer := baueIndex().Search("de", 10); len(treffer) > 0 {
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(treffer))
if hits := buildIndex().Search("de", 10); len(hits) > 0 {
t.Errorf("zweibuchstabiges Präfix lieferte %d Treffer", len(hits))
}
}
@ -82,32 +82,32 @@ func TestSearchExactBeatsPrefix(t *testing.T) {
idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
idx.Index("praefix", "Test", "Hier steht sshd, also nur ein Wortanfang.", nil)
treffer := idx.Search("ssh", 10)
hits := idx.Search("ssh", 10)
if len(treffer) == 0 {
if len(hits) == 0 {
t.Fatal("keine Treffer")
}
if treffer[0].DocID != "exakt" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
if hits[0].DocID != "exakt" {
t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID)
}
}
func TestSearchTagsAreIndexed(t *testing.T) {
treffer := baueIndex().Search("sicherheit", 10)
hits := buildIndex().Search("sicherheit", 10)
if len(treffer) != 1 || treffer[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", ergebnisIDs(treffer))
if len(hits) != 1 || hits[0].DocID != "server-zugang" {
t.Errorf("Treffer = %v — Schlagworte fließen nicht in den Index ein", resultIDs(hits))
}
}
func TestSearchIsCaseInsensitive(t *testing.T) {
idx := baueIndex()
idx := buildIndex()
klein := idx.Search("bastion", 10)
gross := idx.Search("BASTION", 10)
lower := idx.Search("bastion", 10)
upper := idx.Search("BASTION", 10)
if len(klein) != len(gross) || len(klein) == 0 {
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(klein), len(gross))
if len(lower) != len(upper) || len(lower) == 0 {
t.Errorf("Groß-/Kleinschreibung macht einen Unterschied: %d vs. %d", len(lower), len(upper))
}
}
@ -117,14 +117,14 @@ func TestSearchRespectsLimit(t *testing.T) {
idx.Index(strings.Repeat("x", i+1), "Titel", "gemeinsames Stichwort", nil)
}
if treffer := idx.Search("gemeinsames", 5); len(treffer) != 5 {
t.Errorf("%d Treffer, erwartet 5", len(treffer))
if hits := idx.Search("gemeinsames", 5); len(hits) != 5 {
t.Errorf("%d Treffer, erwartet 5", len(hits))
}
}
func TestSearchEmptyQuery(t *testing.T) {
if treffer := baueIndex().Search("", 10); len(treffer) != 0 {
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(treffer))
if hits := buildIndex().Search("", 10); len(hits) != 0 {
t.Errorf("eine leere Anfrage lieferte %d Treffer", len(hits))
}
}
@ -135,32 +135,32 @@ func TestIndexUpdateReplaces(t *testing.T) {
idx.Index("eintrag", "Alt", "Das alte Stichwort lautet Apfelbaum.", nil)
idx.Index("eintrag", "Neu", "Das neue Stichwort lautet Birnbaum.", nil)
if treffer := idx.Search("apfelbaum", 10); len(treffer) != 0 {
if hits := idx.Search("apfelbaum", 10); len(hits) != 0 {
t.Error("der alte Inhalt ist noch im Index")
}
if treffer := idx.Search("birnbaum", 10); len(treffer) != 1 {
if hits := idx.Search("birnbaum", 10); len(hits) != 1 {
t.Error("der neue Inhalt fehlt im Index")
}
}
func TestIndexRemove(t *testing.T) {
idx := baueIndex()
idx := buildIndex()
idx.Remove("server-zugang")
if treffer := idx.Search("bastion", 10); len(treffer) != 0 {
if hits := idx.Search("bastion", 10); len(hits) != 0 {
t.Error("der entfernte Eintrag ist noch auffindbar")
}
// Die übrigen Einträge müssen unberührt bleiben.
if treffer := idx.Search("onboarding", 10); len(treffer) == 0 {
if hits := idx.Search("onboarding", 10); len(hits) == 0 {
t.Error("beim Entfernen wurde zu viel gelöscht")
}
}
func TestRemoveUnknownIsHarmless(t *testing.T) {
idx := baueIndex()
idx := buildIndex()
idx.Remove("gibtesnicht")
if treffer := idx.Search("bastion", 10); len(treffer) != 1 {
if hits := idx.Search("bastion", 10); len(hits) != 1 {
t.Error("das Entfernen eines unbekannten Eintrags hat den Index beschädigt")
}
}
@ -183,31 +183,31 @@ func TestTokenizeIgnoresSingleChars(t *testing.T) {
func TestTokenizeSplitsOnPunctuation(t *testing.T) {
got := tokenize("GitHub-Actions, SSH/SCP; done.")
erwartet := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true}
want := map[string]bool{"github": true, "actions": true, "ssh": true, "scp": true, "done": true}
for _, token := range got {
if !erwartet[token] {
if !want[token] {
t.Errorf("unerwartetes Token %q in %v", token, got)
}
}
if len(got) != len(erwartet) {
t.Errorf("tokenize = %v, erwartet %d Token", got, len(erwartet))
if len(got) != len(want) {
t.Errorf("tokenize = %v, erwartet %d Token", got, len(want))
}
}
// ── Umlaute ──────────────────────────────────────────────────────────────────
// TestUmlautfaltung prüft, dass Umlaute und ß beim Suchen keine Rolle spielen.
// TestUmlautFolding prüft, dass Umlaute und ß beim Suchen keine Rolle spielen.
//
// Ohne das findet „prufung" den Eintrag „Prüfung" nicht — für den Index sind es
// zwei verschiedene Wörter. Genau so tippt aber, wer auf einer fremden Tastatur
// sitzt.
func TestUmlautfaltung(t *testing.T) {
func TestUmlautFolding(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Prüfung der Größe",
"Bei der Prüfung ergab sich eine Störung. Die Maßnahme läuft.", []string{"prüfung"})
// Jeweils: mit Umlaut geschrieben und ohne — beide müssen treffen.
paare := [][2]string{
pairs := [][2]string{
{"prüfung", "prufung"},
{"größe", "grosse"},
{"störung", "storung"},
@ -215,28 +215,28 @@ func TestUmlautfaltung(t *testing.T) {
{"läuft", "lauft"},
}
for _, paar := range paare {
for _, anfrage := range paar {
if treffer := idx.Search(anfrage, 10); len(treffer) == 0 {
t.Errorf("Suche nach %q fand nichts", anfrage)
for _, pair := range pairs {
for _, query := range pair {
if hits := idx.Search(query, 10); len(hits) == 0 {
t.Errorf("Suche nach %q fand nichts", query)
}
}
}
}
// TestUmlautfaltungImSchlagwort prüft, dass auch Schlagworte gefaltet werden.
func TestUmlautfaltungImSchlagwort(t *testing.T) {
// TestUmlautFoldingInTag prüft, dass auch Schlagworte gefaltet werden.
func TestUmlautFoldingInTag(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Titel", "Inhalt", []string{"prüfung"})
if treffer := idx.Search("prufung", 10); len(treffer) == 0 {
if hits := idx.Search("prufung", 10); len(hits) == 0 {
t.Error("Schlagwort mit Umlaut ist ohne Umlaut nicht auffindbar")
}
}
// TestFaltungVerschontNormaleWoerter stellt sicher, dass die Faltung nicht mehr
// TestFoldingSparesNormalWords stellt sicher, dass die Faltung nicht mehr
// anfasst als nötig — „neue" darf nicht zu „nu" werden.
func TestFaltungVerschontNormaleWoerter(t *testing.T) {
func TestFoldingSparesNormalWords(t *testing.T) {
cases := map[string]string{
"neue": "neue",
"steuer": "steuer",
@ -246,64 +246,64 @@ func TestFaltungVerschontNormaleWoerter(t *testing.T) {
"tür": "tur",
}
for eingabe, erwartet := range cases {
got := tokenize(eingabe)
if len(got) != 1 || got[0] != erwartet {
t.Errorf("tokenize(%q) = %v, erwartet [%s]", eingabe, got, erwartet)
for input, want := range cases {
got := tokenize(input)
if len(got) != 1 || got[0] != want {
t.Errorf("tokenize(%q) = %v, erwartet [%s]", input, got, want)
}
}
}
// TestPraefixMitUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
func TestPraefixMitUmlaut(t *testing.T) {
// TestPrefixWithUmlaut prüft das Zusammenspiel von Faltung und Präfix-Suche.
func TestPrefixWithUmlaut(t *testing.T) {
idx := NewIndex()
idx.Index("eintrag", "Überwachung", "Die Überwachung meldet Störungen.", nil)
// "uberwach" ist Präfix von "uberwachung" — beides gefaltet.
if treffer := idx.Search("uberwach", 10); len(treffer) == 0 {
if hits := idx.Search("uberwach", 10); len(hits) == 0 {
t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
}
}
// ── Bevorzugung (Boost) ──────────────────────────────────────────────────────
// TestBoostBevorzugtBeiGleicherRelevanz prüft, dass der Faktor bei sonst
// TestBoostPrefersOnEqualRelevance prüft, dass der Faktor bei sonst
// gleichen Dokumenten den Ausschlag gibt.
func TestBoostBevorzugtBeiGleicherRelevanz(t *testing.T) {
func TestBoostPrefersOnEqualRelevance(t *testing.T) {
idx := NewIndex()
idx.Index("normal", "Deployment", "Ablauf.", nil)
idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2)
treffer := idx.Search("deployment", 10)
hits := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "bevorzugt" {
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", ergebnisIDs(treffer))
if len(hits) != 2 || hits[0].DocID != "bevorzugt" {
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", resultIDs(hits))
}
}
// TestBoostVerdraengtKeinenGenauenTreffer prüft, dass der kleine Faktor einen
// TestBoostDoesNotDisplaceExactHit prüft, dass der kleine Faktor einen
// klar besser passenden Eintrag nicht überholt.
func TestBoostVerdraengtKeinenGenauenTreffer(t *testing.T) {
func TestBoostDoesNotDisplaceExactHit(t *testing.T) {
idx := NewIndex()
idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil)
idx.IndexWithBoost("rand", "Wartung", "Allgemeines, unter anderem ein Zertifikatswechsel.", nil, 1.2)
treffer := idx.Search("zertifikatswechsel", 10)
hits := idx.Search("zertifikatswechsel", 10)
if len(treffer) != 2 || treffer[0].DocID != "genau" {
t.Errorf("Treffer = %v, erwartet genau zuerst", ergebnisIDs(treffer))
if len(hits) != 2 || hits[0].DocID != "genau" {
t.Errorf("Treffer = %v, erwartet genau zuerst", resultIDs(hits))
}
}
// TestBoostOhneWertIstNeutral prüft, dass ein Faktor ≤ 0 kein Dokument
// TestBoostWithoutValueIsNeutral prüft, dass ein Faktor ≤ 0 kein Dokument
// unauffindbar macht.
func TestBoostOhneWertIstNeutral(t *testing.T) {
func TestBoostWithoutValueIsNeutral(t *testing.T) {
idx := NewIndex()
idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0)
treffer := idx.Search("deployment", 10)
hits := idx.Search("deployment", 10)
if len(treffer) != 1 || treffer[0].Score <= 0 {
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", treffer)
if len(hits) != 1 || hits[0].Score <= 0 {
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", hits)
}
}

View file

@ -26,39 +26,39 @@ import (
// Gewichte der Varianten — ein exakter Treffer zählt 1.
const (
// gewichtWortanfang gilt für Wörter, die mit dem Suchwort oder seinem
// weightWordPrefix gilt für Wörter, die mit dem Suchwort oder seinem
// Stamm beginnen: „deploy" → „deployment".
gewichtWortanfang = 0.5
weightWordPrefix = 0.5
// gewichtTeilwort gilt für Wörter, die das Suchwort irgendwo enthalten:
// weightSubword gilt für Wörter, die das Suchwort irgendwo enthalten:
// „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil
// Teilwörter öfter zufällig passen.
gewichtTeilwort = 0.3
weightSubword = 0.3
// gewichtTippfehler gilt für Wörter, die sich um ein oder zwei Buchstaben
// weightTypo gilt für Wörter, die sich um ein oder zwei Buchstaben
// unterscheiden: „deploymnet" → „deployment".
gewichtTippfehler = 0.3
weightTypo = 0.3
)
// Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter
// passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port"
// steckt in „Reporting", „Export" und „Support".
const (
minWortanfang = 3
minTeilwort = 5
minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt
minTippfehler = 4
minWordPrefix = 3
minSubword = 5
minStem = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt
minTypo = 4
)
// variante ist eine Gruppe von Fundstellen, die über dieselbe Art von
// variant ist eine Gruppe von Fundstellen, die über dieselbe Art von
// Ähnlichkeit gefunden wurden, samt ihrem Gewicht.
type variante struct {
type variant struct {
postings []posting
gewicht float64
weight float64
}
// variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den
// Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst
// variantsFor sucht alle Fundstellen eines Suchworts: exakt, über den
// Wortanfang, als Teilwort und — nur wenn typo gesetzt ist und sonst
// gar nichts passt — mit Tippfehlern.
//
// Muss unter der Lesesperre des Index aufgerufen werden.
@ -69,85 +69,85 @@ type variante struct {
// geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen
// (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige
// Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar.
func (s *Index) variantenFuer(term string, tippfehler bool) []variante {
var ergebnis []variante
func (s *Index) variantsFor(term string, typo bool) []variant {
var result []variant
if exakt := s.invertedIndex[term]; len(exakt) > 0 {
ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1})
if exact := s.invertedIndex[term]; len(exact) > 0 {
result = append(result, variant{postings: exact, weight: 1})
}
stamm := kuerzeEndung(term)
sucheWortanfang := len(term) >= minWortanfang
sucheTeilwort := len(term) >= minTeilwort
stem := trimSuffix(term)
searchWordPrefix := len(term) >= minWordPrefix
searchSubword := len(term) >= minSubword
// Je Variante: Dokument → höchste Häufigkeit eines passenden Worts.
// Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment"
// und „deployt" im selben Eintrag sollen nicht dreifach zählen.
wortanfang := make(map[string]int)
teilwort := make(map[string]int)
wordPrefix := make(map[string]int)
subword := make(map[string]int)
for wort, postings := range s.invertedIndex {
for word, postings := range s.invertedIndex {
switch {
case sucheWortanfang && strings.HasPrefix(wort, term):
merkeHoechste(wortanfang, postings)
case stamm != "" && strings.HasPrefix(wort, stamm):
merkeHoechste(wortanfang, postings)
case sucheTeilwort && strings.Contains(wort, term):
merkeHoechste(teilwort, postings)
case searchWordPrefix && strings.HasPrefix(word, term):
keepHighest(wordPrefix, postings)
case stem != "" && strings.HasPrefix(word, stem):
keepHighest(wordPrefix, postings)
case searchSubword && strings.Contains(word, term):
keepHighest(subword, postings)
}
}
if len(wortanfang) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang})
if len(wordPrefix) > 0 {
result = append(result, variant{postings: asPostings(wordPrefix), weight: weightWordPrefix})
}
if len(teilwort) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort})
if len(subword) > 0 {
result = append(result, variant{postings: asPostings(subword), weight: weightSubword})
}
// Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste
// Variante und kosten den meisten Rechenaufwand.
if tippfehler && len(ergebnis) == 0 {
if treffer := s.tippfehlerPostings(term); len(treffer) > 0 {
ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler})
if typo && len(result) == 0 {
if hits := s.typoPostings(term); len(hits) > 0 {
result = append(result, variant{postings: hits, weight: weightTypo})
}
}
return ergebnis
return result
}
// tippfehlerPostings sammelt die Fundstellen aller Wörter, die sich vom
// typoPostings sammelt die Fundstellen aller Wörter, die sich vom
// Suchwort nur um wenige Buchstaben unterscheiden.
func (s *Index) tippfehlerPostings(term string) []posting {
suchwort := []rune(term)
erlaubt := erlaubteTippfehler(len(suchwort))
if erlaubt == 0 {
func (s *Index) typoPostings(term string) []posting {
searchWord := []rune(term)
allowed := allowedTypos(len(searchWord))
if allowed == 0 {
return nil
}
beste := make(map[string]int)
for wort, postings := range s.invertedIndex {
best := make(map[string]int)
for word, postings := range s.invertedIndex {
// Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als
// erlaubt, kann der Abstand nicht kleiner sein. Das erspart die
// eigentliche Rechnung für fast alle Wörter.
laenge := utf8.RuneCountInString(wort)
if abs(laenge-len(suchwort)) > erlaubt {
length := utf8.RuneCountInString(word)
if abs(length-len(searchWord)) > allowed {
continue
}
if levenshtein(suchwort, []rune(wort)) <= erlaubt {
merkeHoechste(beste, postings)
if levenshtein(searchWord, []rune(word)) <= allowed {
keepHighest(best, postings)
}
}
return alsPostings(beste)
return asPostings(best)
}
// erlaubteTippfehler legt fest, wie viele Buchstaben bei einem Wort dieser
// allowedTypos legt fest, wie viele Buchstaben bei einem Wort dieser
// Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel:
// „host" und „post" sind verschiedene Dinge.
func erlaubteTippfehler(laenge int) int {
func allowedTypos(length int) int {
switch {
case laenge < minTippfehler:
case length < minTypo:
return 0
case laenge < 8:
case length < 8:
return 1
default:
return 2
@ -165,33 +165,33 @@ func erlaubteTippfehler(laenge int) int {
// Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier
// zwei Zeilen, die abwechselnd überschrieben werden.
func levenshtein(a, b []rune) int {
vorher := make([]int, len(b)+1)
aktuell := make([]int, len(b)+1)
before := make([]int, len(b)+1)
current := make([]int, len(b)+1)
// Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen.
for j := range vorher {
vorher[j] = j
for j := range before {
before[j] = j
}
for i := 1; i <= len(a); i++ {
aktuell[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen
current[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen
for j := 1; j <= len(b); j++ {
kosten := 1
cost := 1
if a[i-1] == b[j-1] {
kosten = 0
cost = 0
}
aktuell[j] = min(
vorher[j]+1, // Buchstabe aus a löschen
aktuell[j-1]+1, // Buchstabe aus b einfügen
vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen)
current[j] = min(
before[j]+1, // Buchstabe aus a löschen
current[j-1]+1, // Buchstabe aus b einfügen
before[j-1]+cost, // Buchstabe ersetzen (oder gleich lassen)
)
}
vorher, aktuell = aktuell, vorher
before, current = current, before
}
return vorher[len(b)]
return before[len(b)]
}
// endungen sind die Wortendungen, die kuerzeEndung abschneidet — die
// suffixes sind die Wortendungen, die trimSuffix abschneidet — die
// häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s.
// Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht
// zu „prozesse".
@ -199,9 +199,9 @@ func levenshtein(a, b []rune) int {
// Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort
// („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch
// „Service" fände.
var endungen = []string{"en", "es", "e", "n", "s"}
var suffixes = []string{"en", "es", "e", "n", "s"}
// kuerzeEndung schneidet eine Beugungsendung ab und gibt den Stamm zurück —
// trimSuffix schneidet eine Beugungsendung ab und gibt den Stamm zurück —
// oder "", wenn es nichts zu kürzen gibt.
//
// deployen → deploy (findet dann „deployment")
@ -213,23 +213,23 @@ var endungen = []string{"en", "es", "e", "n", "s"}
// Regeln und verfälschen dabei Fachbegriffe. Hier geht es nur darum, dass ein
// Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als
// Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate".
func kuerzeEndung(term string) string {
for _, endung := range endungen {
if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm {
return term[:len(term)-len(endung)]
func trimSuffix(term string) string {
for _, suffix := range suffixes {
if strings.HasSuffix(term, suffix) && len(term)-len(suffix) >= minStem {
return term[:len(term)-len(suffix)]
}
}
return ""
}
// stoppwoerter sind Wörter, die in fast jeder Frage stehen, aber nichts über
// stopwords sind Wörter, die in fast jeder Frage stehen, aber nichts über
// das Thema verraten.
//
// # Warum nur beim Suchen?
//
// Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der
// Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es
// weiterhin (siehe ohneStoppwoerter).
// weiterhin (siehe withoutStopwords).
//
// # Warum überhaupt?
//
@ -243,7 +243,7 @@ func kuerzeEndung(term string) string {
//
// Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it"
// oder „go".
var stoppwoerter = map[string]bool{
var stopwords = map[string]bool{
// Deutsch
"der": true, "die": true, "das": true, "den": true, "dem": true, "des": true,
"ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": true,
@ -269,15 +269,15 @@ var stoppwoerter = map[string]bool{
"can": true, "this": true, "that": true,
}
// ohneStoppwoerter entfernt die Stoppwörter aus einer Anfrage.
// withoutStopwords entfernt die Stoppwörter aus einer Anfrage.
//
// Besteht die Anfrage nur aus Stoppwörtern, bleibt sie unverändert — dann
// meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe
// niemandem.
func ohneStoppwoerter(tokens []string) []string {
func withoutStopwords(tokens []string) []string {
rest := make([]string, 0, len(tokens))
for _, t := range tokens {
if !stoppwoerter[t] {
if !stopwords[t] {
rest = append(rest, t)
}
}
@ -287,22 +287,22 @@ func ohneStoppwoerter(tokens []string) []string {
return rest
}
// merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein.
func merkeHoechste(beste map[string]int, postings []posting) {
// keepHighest trägt für jedes Dokument die höchste Häufigkeit ein.
func keepHighest(best map[string]int, postings []posting) {
for _, p := range postings {
if p.freq > beste[p.docID] {
beste[p.docID] = p.freq
if p.freq > best[p.docID] {
best[p.docID] = p.freq
}
}
}
// alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um.
func alsPostings(beste map[string]int) []posting {
ergebnis := make([]posting, 0, len(beste))
for docID, freq := range beste {
ergebnis = append(ergebnis, posting{docID: docID, freq: freq})
// asPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um.
func asPostings(best map[string]int) []posting {
result := make([]posting, 0, len(best))
for docID, freq := range best {
result = append(result, posting{docID: docID, freq: freq})
}
return ergebnis
return result
}
// abs gibt den Betrag einer ganzen Zahl zurück.
@ -313,7 +313,7 @@ func abs(n int) int {
return n
}
// Ueberdeckung gibt an, welcher Anteil der Wörter aus anfrage in text
// Coverage gibt an, welcher Anteil der Wörter aus query in text
// vorkommt — als Zahl von 0 (keines) bis 1 (alle). Stoppwörter zählen nicht.
//
// Gedacht für kurze Texte wie Titel, um Dubletten zu erkennen (siehe remember
@ -332,15 +332,15 @@ func abs(n int) int {
// der Größe des Projekts und der Länge der Einträge ab. Eine Schwelle darauf
// müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist
// dagegen in jedem Projekt gleich zu lesen.
func Ueberdeckung(anfrage, text string) float64 {
anteil, _ := UeberdeckungMitSuchbegriffen(anfrage, text, "")
return anteil
func Coverage(query, text string) float64 {
share, _ := CoverageWithSearchTerms(query, text, "")
return share
}
// UeberdeckungMitSuchbegriffen ist Ueberdeckung für einen vorhandenen
// CoverageWithSearchTerms ist Ueberdeckung für einen vorhandenen
// Eintrag, der aus zwei Teilen besteht: seinem Titel und seinen Suchbegriffen
// (der Zeile „Auch gesucht als: …"). Zurück kommen der Anteil und die Wörter
// aus anfrage, die gefunden wurden — in der Form, die tokenize erzeugt.
// aus query, die gefunden wurden — in der Form, die tokenize erzeugt.
//
// # Warum zwei Teile?
//
@ -353,65 +353,65 @@ func Ueberdeckung(anfrage, text string) float64 {
//
// „Release-Ablauf" vs. Suchbegriff „release" → zählt (wörtlich)
// „Betrieb mit PostgreSQL" vs. Suchbegriff „postgres" → zählt nicht
func UeberdeckungMitSuchbegriffen(anfrage, titel, suchbegriffe string) (float64, []string) {
woerter := ohneStoppwoerter(tokenize(anfrage))
if len(woerter) == 0 {
func CoverageWithSearchTerms(query, title, searchTerms string) (float64, []string) {
words := withoutStopwords(tokenize(query))
if len(words) == 0 {
return 0, nil
}
imTitel := tokenize(titel)
wortgenau := make(map[string]bool)
for _, w := range tokenize(suchbegriffe) {
wortgenau[w] = true
inTitle := tokenize(title)
wordExact := make(map[string]bool)
for _, w := range tokenize(searchTerms) {
wordExact[w] = true
}
// Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht
// zu zwei Dritteln „Server".
gesehen := make(map[string]bool, len(woerter))
gesamt := 0
var gefunden []string
for _, w := range woerter {
if gesehen[w] {
seen := make(map[string]bool, len(words))
total := 0
var found []string
for _, w := range words {
if seen[w] {
continue
}
gesehen[w] = true
gesamt++
seen[w] = true
total++
if wortgenau[w] {
gefunden = append(gefunden, w)
if wordExact[w] {
found = append(found, w)
continue
}
for _, v := range imTitel {
if passtZu(w, v) {
gefunden = append(gefunden, w)
for _, v := range inTitle {
if matches(w, v) {
found = append(found, w)
break
}
}
}
return float64(len(gefunden)) / float64(gesamt), gefunden
return float64(len(found)) / float64(total), found
}
// passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten.
func passtZu(a, b string) bool {
// matches prüft, ob zwei einzelne Wörter als dasselbe gelten.
func matches(a, b string) bool {
if a == b {
return true
}
// Wortanfang, in beide Richtungen: „deploy" ↔ „deployment",
// „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt
// „de" zu allem.
if len(a) >= minStamm && len(b) >= minStamm &&
if len(a) >= minStem && len(b) >= minStem &&
(strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) {
return true
}
// Endung: „zertifikate" ↔ „zertifikat".
if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) {
if stem := trimSuffix(a); stem != "" && strings.HasPrefix(b, stem) {
return true
}
if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) {
if stem := trimSuffix(b); stem != "" && strings.HasPrefix(a, stem) {
return true
}
// Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess".
if len(a) >= minTeilwort && strings.Contains(b, a) {
if len(a) >= minSubword && strings.Contains(b, a) {
return true
}
return len(b) >= minTeilwort && strings.Contains(a, b)
return len(b) >= minSubword && strings.Contains(a, b)
}

View file

@ -6,124 +6,124 @@ import (
"testing"
)
// enthaelt prüft, ob ein Eintrag unter den Treffern ist.
func enthaelt(treffer []Result, docID string) bool {
return slices.Contains(ergebnisIDs(treffer), docID)
// contains prüft, ob ein Eintrag unter den Treffern ist.
func contains(hits []Result, docID string) bool {
return slices.Contains(resultIDs(hits), docID)
}
// ── Wortanfang ───────────────────────────────────────────────────────────────
// TestWortanfangNebenExaktemTreffer prüft den Fehler, der zu diesem Umbau
// TestWordPrefixBesideExactHit prüft den Fehler, der zu diesem Umbau
// geführt hat: Früher griff die Suche nach dem Wortanfang nur, wenn es keinen
// exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
// den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso
// häufiger wird, je größer ein Projekt ist.
func TestWortanfangNebenExaktemTreffer(t *testing.T) {
func TestWordPrefixBesideExactHit(t *testing.T) {
idx := NewIndex()
idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil)
idx.Index("prozess", "Deployment-Prozess", "So kommt eine neue Fassung in Produktion.", nil)
treffer := idx.Search("deploy", 10)
hits := idx.Search("deploy", 10)
if !enthaelt(treffer, "prozess") {
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer))
if !contains(hits, "prozess") {
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", resultIDs(hits))
}
if treffer[0].DocID != "skript" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
if hits[0].DocID != "skript" {
t.Errorf("bester Treffer = %q, erwartet den exakten", hits[0].DocID)
}
}
// TestWortanfangZaehltNichtDoppelt prüft, dass ein Eintrag mit vielen Wörtern
// TestWordPrefixNotCountedTwice prüft, dass ein Eintrag mit vielen Wörtern
// desselben Anfangs nicht vor einem exakten Treffer landet.
func TestWortanfangZaehltNichtDoppelt(t *testing.T) {
func TestWordPrefixNotCountedTwice(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Hinweis", "Hier steht config.", nil)
idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil)
treffer := idx.Search("config", 10)
hits := idx.Search("config", 10)
if len(treffer) != 2 || treffer[0].DocID != "exakt" {
t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer))
if len(hits) != 2 || hits[0].DocID != "exakt" {
t.Errorf("Treffer = %v, erwartet exakt vor viele", resultIDs(hits))
}
}
// ── Zusammengesetzte Wörter ──────────────────────────────────────────────────
// TestTeilwortFindetKomposita prüft zusammengeschriebene deutsche Wörter. Mit
// TestSubwordFindsCompounds prüft zusammengeschriebene deutsche Wörter. Mit
// Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter;
// zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein
// Wortanfang.
func TestTeilwortFindetKomposita(t *testing.T) {
func TestSubwordFindsCompounds(t *testing.T) {
idx := NewIndex()
idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil)
idx.Index("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil)
idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil)
faelle := map[string]string{
cases := map[string]string{
"prozess": "release",
"wechsel": "zertifikat",
"zugang": "datenbank",
"datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen
}
for anfrage, erwartet := range faelle {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
for query, want := range cases {
if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
}
}
// TestTeilwortBrauchtFuenfZeichen prüft die Untergrenze: Kurze Wörter stecken
// TestSubwordNeedsFiveChars prüft die Untergrenze: Kurze Wörter stecken
// zufällig in zu vielen anderen.
func TestTeilwortBrauchtFuenfZeichen(t *testing.T) {
func TestSubwordNeedsFiveChars(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil)
if treffer := idx.Search("port", 10); len(treffer) > 0 {
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer))
if hits := idx.Search("port", 10); len(hits) > 0 {
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", resultIDs(hits))
}
}
// TestExaktVorTeilwort prüft die Gewichtung: Das eigenständige Wort steht vor
// TestExactBeforeSubword prüft die Gewichtung: Das eigenständige Wort steht vor
// dem Wort, in dem es nur steckt.
func TestExaktVorTeilwort(t *testing.T) {
func TestExactBeforeSubword(t *testing.T) {
idx := NewIndex()
idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil)
idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil)
treffer := idx.Search("prozess", 10)
hits := idx.Search("prozess", 10)
if len(treffer) != 2 || treffer[0].DocID != "eigenstaendig" {
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer))
if len(hits) != 2 || hits[0].DocID != "eigenstaendig" {
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", resultIDs(hits))
}
}
// ── Endungen ─────────────────────────────────────────────────────────────────
// TestEndungenWerdenAusgeglichen prüft Suchwörter, die länger sind als das
// TestSuffixesAreNormalized prüft Suchwörter, die länger sind als das
// Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen"
// ist kein Anfang von „deployment".
func TestEndungenWerdenAusgeglichen(t *testing.T) {
func TestSuffixesAreNormalized(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil)
idx.Index("datenbank", "Datenbank", "Sicherung.", nil)
faelle := map[string]string{
cases := map[string]string{
"deployen": "deployment",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
}
for anfrage, erwartet := range faelle {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
for query, want := range cases {
if hits := idx.Search(query, 10); !contains(hits, want) {
t.Errorf("Search(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
}
}
func TestKuerzeEndung(t *testing.T) {
faelle := map[string]string{
func TestTrimSuffix(t *testing.T) {
cases := map[string]string{
"deployen": "deploy",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
@ -134,96 +134,96 @@ func TestKuerzeEndung(t *testing.T) {
"server": "", // „-er" wird bewusst nicht gekürzt
}
for eingabe, erwartet := range faelle {
if got := kuerzeEndung(eingabe); got != erwartet {
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet)
for input, want := range cases {
if got := trimSuffix(input); got != want {
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", input, got, want)
}
}
}
// ── Stoppwörter ──────────────────────────────────────────────────────────────
// TestStoppwoerterErzeugenKeinRauschen prüft eine typische Agenten-Frage.
// TestStopwordsCreateNoNoise prüft eine typische Agenten-Frage.
// Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung".
func TestStoppwoerterErzeugenKeinRauschen(t *testing.T) {
func TestStopwordsCreateNoNoise(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil)
idx.Index("kuehlung", "Kühlung", "Die Wirkung der Lüfter im Serverraum.", nil)
treffer := idx.Search("wie deployen wir", 10)
hits := idx.Search("wie deployen wir", 10)
if !enthaelt(treffer, "deployment") {
t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer))
if !contains(hits, "deployment") {
t.Errorf("Treffer = %v, erwartet deployment", resultIDs(hits))
}
if enthaelt(treffer, "kuehlung") {
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer))
if contains(hits, "kuehlung") {
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", resultIDs(hits))
}
}
// TestNurStoppwoerterBleibtSuchbar prüft die Ausnahme: Besteht die Anfrage nur
// TestOnlyStopwordsStaysSearchable prüft die Ausnahme: Besteht die Anfrage nur
// aus Stoppwörtern, wird trotzdem gesucht.
func TestNurStoppwoerterBleibtSuchbar(t *testing.T) {
func TestOnlyStopwordsStaysSearchable(t *testing.T) {
idx := NewIndex()
idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil)
if treffer := idx.Search("wie", 10); len(treffer) == 0 {
if hits := idx.Search("wie", 10); len(hits) == 0 {
t.Error("eine Anfrage nur aus Stoppwörtern fand nichts")
}
}
// TestStoppwoerterSindGefaltet stellt sicher, dass jedes Stoppwort in der Form
// TestStopwordsAreFolded stellt sicher, dass jedes Stoppwort in der Form
// in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur",
// würde es nie greifen.
func TestStoppwoerterSindGefaltet(t *testing.T) {
for wort := range stoppwoerter {
if got := tokenize(wort); len(got) != 1 || got[0] != wort {
t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", wort, got)
func TestStopwordsAreFolded(t *testing.T) {
for word := range stopwords {
if got := tokenize(word); len(got) != 1 || got[0] != word {
t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", word, got)
}
}
}
// ── Tippfehler ───────────────────────────────────────────────────────────────
// TestTippfehlerNurInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen
// TestTypoOnlyInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen
// werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten.
func TestTippfehlerNurInSearchFuzzy(t *testing.T) {
func TestTypoOnlyInSearchFuzzy(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("server", "Server", "Wartung.", nil)
faelle := map[string]string{
cases := map[string]string{
"deploymnet": "deployment", // zwei vertauschte Buchstaben
"sever": "server", // ein Buchstabe fehlt
}
for anfrage, erwartet := range faelle {
if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
for query, want := range cases {
if hits := idx.SearchFuzzy(query, 10); !contains(hits, want) {
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", query, resultIDs(hits), want)
}
if treffer := idx.Search(anfrage, 10); len(treffer) > 0 {
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer))
if hits := idx.Search(query, 10); len(hits) > 0 {
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", query, resultIDs(hits))
}
}
}
// TestTippfehlerNurAlsRueckfall prüft, dass die Tippfehler-Suche nicht greift,
// TestTypoOnlyAsFallback prüft, dass die Tippfehler-Suche nicht greift,
// wenn das Suchwort schon etwas findet — sonst fände „host" auch „post".
func TestTippfehlerNurAlsRueckfall(t *testing.T) {
func TestTypoOnlyAsFallback(t *testing.T) {
idx := NewIndex()
idx.Index("host", "Host", "Rechner im Netz.", nil)
idx.Index("cost", "Cost", "Kostenstelle.", nil)
treffer := idx.SearchFuzzy("host", 10)
hits := idx.SearchFuzzy("host", 10)
if len(treffer) != 1 || treffer[0].DocID != "host" {
t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer))
if len(hits) != 1 || hits[0].DocID != "host" {
t.Errorf("Treffer = %v, erwartet nur host", resultIDs(hits))
}
}
func TestLevenshtein(t *testing.T) {
faelle := []struct {
a, b string
abstand int
cases := []struct {
a, b string
distance int
}{
{"server", "server", 0},
{"sever", "server", 1}, // einfügen
@ -234,33 +234,33 @@ func TestLevenshtein(t *testing.T) {
{"prüfung", "prufung", 1}, // Runen, nicht Bytes
}
for _, f := range faelle {
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand {
t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.abstand)
for _, f := range cases {
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.distance {
t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.distance)
}
}
}
func TestErlaubteTippfehler(t *testing.T) {
faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
func TestAllowedTypos(t *testing.T) {
cases := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
for laenge, erwartet := range faelle {
if got := erlaubteTippfehler(laenge); got != erwartet {
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet)
for length, want := range cases {
if got := allowedTypos(length); got != want {
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", length, got, want)
}
}
}
// ── Überdeckung (Dubletten) ──────────────────────────────────────────────────
// TestUeberdeckungMitSuchbegriffen: Im Titel zählen alle Varianten, ein
// TestCoverageWithSearchTerms: Im Titel zählen alle Varianten, ein
// Suchbegriff nur wörtlich. Dazu kommen die gefundenen Wörter zurück.
func TestUeberdeckungMitSuchbegriffen(t *testing.T) {
faelle := []struct {
name string
anfrage, titel, suchbegriffe string
anteil float64
gemeinsam string
func TestCoverageWithSearchTerms(t *testing.T) {
cases := []struct {
name string
query, title, searchTerms string
share float64
shared string
}{
{"Suchbegriff wörtlich", "Release-Ablauf", "Deployment-Prozess", "release, rollout", 0.5, "release"},
{"Suchbegriff nur als Wortanfang zählt nicht", "Betrieb mit PostgreSQL", "Ausbaustand", "roadmap, postgres-migration", 0, ""},
@ -269,20 +269,20 @@ func TestUeberdeckungMitSuchbegriffen(t *testing.T) {
{"Umlaute im Suchbegriff", "Übergabe", "Anderes", "uebergabe, übergabe", 1, "ubergabe"},
{"nichts", "VPN-Zugang", "Drucker", "", 0, ""},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
anteil, gemeinsam := UeberdeckungMitSuchbegriffen(f.anfrage, f.titel, f.suchbegriffe)
if anteil != f.anteil || strings.Join(gemeinsam, " ") != f.gemeinsam {
t.Errorf("= %v %q, erwartet %v %q", anteil, gemeinsam, f.anteil, f.gemeinsam)
share, shared := CoverageWithSearchTerms(f.query, f.title, f.searchTerms)
if share != f.share || strings.Join(shared, " ") != f.shared {
t.Errorf("= %v %q, erwartet %v %q", share, shared, f.share, f.shared)
}
})
}
}
func TestUeberdeckung(t *testing.T) {
faelle := []struct {
anfrage, text string
erwartet float64
func TestCoverage(t *testing.T) {
cases := []struct {
query, text string
want float64
}{
{"Deployment-Ablauf", "Deployment-Prozess", 0.5},
{"Releaseprozess", "Release-Prozess", 1},
@ -296,9 +296,9 @@ func TestUeberdeckung(t *testing.T) {
{"Port", "Reporting", 0}, // zu kurz für ein Teilwort
}
for _, f := range faelle {
if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet {
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet)
for _, f := range cases {
if got := Coverage(f.query, f.text); got != f.want {
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.query, f.text, got, f.want)
}
}
}

View file

@ -1,290 +0,0 @@
// Package statistik zählt die Kennzahlen aller Workspaces für die Systemseite
// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die
// Aktivität der letzten 30 Tage.
//
// # Warum nicht bei jedem Seitenaufruf?
//
// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces
// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber
// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens
// alle MindestAbstand neu und gibt dazwischen das gemerkte Ergebnis zurück.
// Die Seite nennt den Stand ("Stand 14:05").
//
// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer
// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf,
// der beim Beenden aufgeräumt werden müsste.
package statistik
import (
"context"
"fmt"
"sort"
"sync"
"time"
"munin/internal/store"
"munin/internal/workspace"
)
// Tage ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines
// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll
// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da.
const Tage = store.UsageWindowDays
// MindestAbstand ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet
// wird.
const MindestAbstand = 5 * time.Minute
// Stand ist das Ergebnis einer Erhebung.
type Stand struct {
Erhoben time.Time
Dauer time.Duration // wie lange die Erhebung brauchte
Workspaces []Workspace // nach Name sortiert
Summe store.Bestand
Belegung store.Belegung // aller Workspaces zusammen
// Schemastand: die neueste Migration, die dieses Programm kennt. Ein
// Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema.
Schemastand string
Tage []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt
Projekte []Projekt // mit Aktivität, die aktivsten zuerst
Ruhig []Projekt // ohne jede Aktivität im Zeitraum, nach Workspace und Name
// Gewartet zählt, wie oft Anfragen seit dem Start auf eine freie
// Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt
// der Erhebung. GewartetVorher und VorherErhoben sind dieselbe Zahl bei
// der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die
// Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start
// sagt das nach Tagen Laufzeit nicht mehr.
Gewartet int64
GewartetVorher int64
VorherErhoben time.Time
// Fehler sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen
// in den Summen; die übrigen sind trotzdem da.
Fehler []string
}
// Workspace sind die Kennzahlen eines Workspace.
type Workspace struct {
ID, Name string
Ort string // Datei oder Schema (store.Speicher.Ort)
Bestand store.Bestand
Belegung store.Belegung
Schema string // zuletzt angewandte Migration
}
// SchemaAbweichend sagt, ob der Workspace auf einem anderen Stand ist als das
// Programm.
func (w Workspace) SchemaAbweichend(soll string) bool { return w.Schema != soll }
// Tag ist die Aktivität eines Tages über alle Workspaces.
type Tag struct {
Datum time.Time // 0 Uhr UTC
SchreibenAgent int
SchreibenMensch int
Suchen int
}
// Schreiben sind alle Schreibvorgänge des Tages.
func (t Tag) Schreiben() int { return t.SchreibenAgent + t.SchreibenMensch }
// Projekt ist die Aktivität eines Projekts im Zeitraum.
type Projekt struct {
Workspace string // Name
ID string
Slug string
Name string
SchreibenAgent int
SchreibenMensch int
Suchen int
}
// Schreiben sind alle Schreibvorgänge des Projekts im Zeitraum.
func (p Projekt) Schreiben() int { return p.SchreibenAgent + p.SchreibenMensch }
// Summe ist alles zusammen — danach wird sortiert.
func (p Projekt) Summe() int { return p.Schreiben() + p.Suchen }
// Sammler erhebt den Stand und merkt ihn sich für MindestAbstand.
type Sammler struct {
control *store.ControlStore
workspaces *workspace.Manager
// mu schützt stand und sorgt zugleich dafür, dass bei zwei
// gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und
// bekommt dasselbe Ergebnis.
mu sync.Mutex
stand Stand
}
// NewSammler legt einen Sammler an.
func NewSammler(control *store.ControlStore, workspaces *workspace.Manager) *Sammler {
return &Sammler{control: control, workspaces: workspaces}
}
// Aktuell liefert einen Stand, der höchstens MindestAbstand alt ist, und
// erhebt dafür nötigenfalls neu.
func (s *Sammler) Aktuell(ctx context.Context, jetzt time.Time) Stand {
s.mu.Lock()
defer s.mu.Unlock()
if !s.stand.Erhoben.IsZero() && jetzt.Sub(s.stand.Erhoben) < MindestAbstand {
return s.stand
}
neu := s.erhebe(ctx, jetzt)
neu.GewartetVorher, neu.VorherErhoben = s.stand.Gewartet, s.stand.Erhoben
s.stand = neu
return s.stand
}
// Gewartet zählt die Wartevorgänge aller gerade offenen Pools zusammen: der
// Kontroll-Datenbank und jedes geöffneten Workspace.
func (s *Sammler) Gewartet() int64 {
summe := s.control.Verbindungen().Gewartet()
for _, w := range s.workspaces.Offen() {
summe += w.Store.Verbindungen().Gewartet()
}
return summe
}
// FuerWorkspace erhebt den Stand eines einzelnen Workspace — für den
// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher:
// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach
// einer Änderung (Key angelegt) sofort den neuen Stand sehen.
func (s *Sammler) FuerWorkspace(ctx context.Context, w store.Workspace, jetzt time.Time) Stand {
return s.erhebeListe(ctx, jetzt, []store.Workspace{w})
}
// erhebe fragt jeden Workspace ab und zählt zusammen.
//
// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun
// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start;
// zusätzliche Verbindungen kostet die Erhebung also nicht.
func (s *Sammler) erhebe(ctx context.Context, jetzt time.Time) Stand {
alle, err := s.control.ListWorkspaces(ctx)
if err != nil {
return Stand{Erhoben: jetzt, Fehler: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}}
}
return s.erhebeListe(ctx, jetzt, alle)
}
// erhebeListe zählt die gegebenen Workspaces zusammen.
func (s *Sammler) erhebeListe(ctx context.Context, jetzt time.Time, alle []store.Workspace) Stand {
beginn := time.Now()
stand := Stand{Erhoben: jetzt}
// Die Tage stehen von vornherein fest, auch die ohne jede Aktivität —
// sonst hätte das Diagramm Lücken, die man nicht sieht.
heute := time.Date(jetzt.UTC().Year(), jetzt.UTC().Month(), jetzt.UTC().Day(), 0, 0, 0, 0, time.UTC)
erster := heute.AddDate(0, 0, -(Tage - 1))
tagNr := map[string]int{}
for i := 0; i < Tage; i++ {
d := erster.AddDate(0, 0, i)
stand.Tage = append(stand.Tage, Tag{Datum: d})
tagNr[d.Format("2006-01-02")] = i
}
for _, w := range alle {
ws, projekte, err := s.erheheWorkspace(ctx, w, jetzt, erster, &stand, tagNr)
if err != nil {
stand.Fehler = append(stand.Fehler, fmt.Sprintf("%s: %v", w.Name, err))
continue
}
stand.Workspaces = append(stand.Workspaces, ws)
stand.Summe = stand.Summe.Plus(ws.Bestand)
stand.Belegung.Daten += ws.Belegung.Daten
stand.Belegung.WAL += ws.Belegung.WAL
for _, p := range projekte {
if p.Summe() > 0 {
stand.Projekte = append(stand.Projekte, p)
} else {
stand.Ruhig = append(stand.Ruhig, p)
}
}
}
sort.Slice(stand.Workspaces, func(i, j int) bool { return stand.Workspaces[i].Name < stand.Workspaces[j].Name })
// Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die
// Reihenfolge beim Neuladen nicht springt.
sort.Slice(stand.Projekte, func(i, j int) bool {
a, b := stand.Projekte[i], stand.Projekte[j]
if a.Summe() != b.Summe() {
return a.Summe() > b.Summe()
}
return a.Workspace+a.Name < b.Workspace+b.Name
})
sort.Slice(stand.Ruhig, func(i, j int) bool {
a, b := stand.Ruhig[i], stand.Ruhig[j]
return a.Workspace+a.Name < b.Workspace+b.Name
})
stand.Gewartet = s.Gewartet()
stand.Dauer = time.Since(beginn)
return stand
}
// erheheWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag
// zählt sie direkt in stand.Tage ein; die je Projekt gibt sie zurück.
func (s *Sammler) erheheWorkspace(ctx context.Context, w store.Workspace, jetzt, seit time.Time,
stand *Stand, tagNr map[string]int) (Workspace, []Projekt, error) {
ergebnis := Workspace{ID: w.ID, Name: w.Name, Ort: s.workspaces.Ort(w.ID)}
offen, err := s.workspaces.Get(w.ID)
if err != nil {
return ergebnis, nil, err
}
st := offen.Store
if ergebnis.Bestand, err = st.Bestand(ctx, jetzt); err != nil {
return ergebnis, nil, err
}
if ergebnis.Belegung, err = st.Belegung(ctx); err != nil {
return ergebnis, nil, err
}
ist, soll, err := st.Schemastand(ctx)
if err != nil {
return ergebnis, nil, err
}
ergebnis.Schema = ist
stand.Schemastand = soll // für alle Workspaces derselbe: er kommt aus dem Programm
// Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die
// ruhigen sind interessant.
liste, err := st.ListProjects(ctx, w.ID)
if err != nil {
return ergebnis, nil, err
}
projekte := make([]Projekt, len(liste))
nachID := map[string]*Projekt{}
for i, p := range liste {
projekte[i] = Projekt{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name}
nachID[p.ID] = &projekte[i]
}
aktivitaet, err := st.Aktivitaet(ctx, seit)
if err != nil {
return ergebnis, nil, err
}
for _, a := range aktivitaet {
if i, ok := tagNr[a.Tag]; ok {
stand.Tage[i].SchreibenAgent += a.SchreibenAgent
stand.Tage[i].SchreibenMensch += a.SchreibenMensch
stand.Tage[i].Suchen += a.Suchen
}
// Zeilen eines inzwischen gelöschten Projekts zählen für den Tag,
// haben aber kein Projekt mehr, dem sie gehören.
if p := nachID[a.ProjektID]; p != nil {
p.SchreibenAgent += a.SchreibenAgent
p.SchreibenMensch += a.SchreibenMensch
p.Suchen += a.Suchen
}
}
return ergebnis, projekte, nil
}

View file

@ -1,124 +0,0 @@
package statistik
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"munin/internal/store"
"munin/internal/workspace"
)
// testUmgebung legt eine Kontroll-Datenbank mit zwei Workspaces an, je einem
// Projekt darin.
func testUmgebung(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) {
t.Helper()
ctx := context.Background()
ordner := t.TempDir()
wsOrdner := filepath.Join(ordner, "workspaces")
if err := os.MkdirAll(wsOrdner, 0750); err != nil {
t.Fatal(err)
}
speicher := store.NewSQLite(filepath.Join(ordner, "control.db"), wsOrdner)
control, err := speicher.OpenControl()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { control.Close() })
manager := workspace.NewManager(speicher)
t.Cleanup(func() { manager.Close() })
var liste []store.Workspace
for _, name := range []string{"Vertrieb", "Entwicklung"} {
ws, err := control.CreateWorkspace(ctx, name)
if err != nil {
t.Fatal(err)
}
offen, err := manager.Get(ws.ID)
if err != nil {
t.Fatal(err)
}
if _, err := offen.Store.CreateProject(ctx, ws.ID, "allgemein", "Allgemein "+name, ""); err != nil {
t.Fatal(err)
}
liste = append(liste, ws)
}
return control, manager, liste
}
// TestErhebung zählt über alle Workspaces zusammen, füllt jeden Tag des
// Zeitraums und trennt aktive von ruhigen Projekten.
func TestErhebung(t *testing.T) {
control, manager, liste := testUmgebung(t)
ctx := context.Background()
// Im Workspace "Vertrieb" schreibt ein Agent einen Eintrag.
offen, _ := manager.Get(liste[0].ID)
projekte, _ := offen.Store.ListProjects(ctx, liste[0].ID)
if _, err := offen.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekte[0].ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent,
}); err != nil {
t.Fatal(err)
}
offen.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: projekte[0].ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP,
})
stand := NewSammler(control, manager).Aktuell(ctx, time.Now())
if len(stand.Fehler) > 0 {
t.Fatalf("Fehler: %v", stand.Fehler)
}
if len(stand.Workspaces) != 2 || stand.Workspaces[0].Name != "Entwicklung" {
t.Errorf("Workspaces %+v, erwartet zwei, nach Name sortiert", stand.Workspaces)
}
if stand.Summe.Projekte != 2 || stand.Summe.Eintraege != 1 {
t.Errorf("Summe %+v, erwartet 2 Projekte und 1 Eintrag", stand.Summe)
}
if stand.Belegung.Daten <= 0 {
t.Error("keine Belegung gezählt")
}
if stand.Schemastand == "" {
t.Error("Schemastand fehlt")
}
if len(stand.Tage) != Tage {
t.Fatalf("%d Tage, erwartet %d", len(stand.Tage), Tage)
}
heute := stand.Tage[len(stand.Tage)-1]
if heute.SchreibenAgent != 1 || heute.Datum.Format("2006-01-02") != time.Now().UTC().Format("2006-01-02") {
t.Errorf("letzter Tag %+v, erwartet heute mit einem Schreibvorgang", heute)
}
if len(stand.Projekte) != 1 || stand.Projekte[0].Workspace != "Vertrieb" {
t.Errorf("aktive Projekte %+v, erwartet nur das im Vertrieb", stand.Projekte)
}
if len(stand.Ruhig) != 1 || stand.Ruhig[0].Workspace != "Entwicklung" {
t.Errorf("ruhige Projekte %+v, erwartet nur das in der Entwicklung", stand.Ruhig)
}
}
// TestMindestAbstand: Innerhalb von MindestAbstand kommt der gemerkte Stand
// zurück, danach ein neuer — der sich die Wartezahl des vorigen merkt.
func TestMindestAbstand(t *testing.T) {
control, manager, _ := testUmgebung(t)
ctx := context.Background()
s := NewSammler(control, manager)
jetzt := time.Now()
erster := s.Aktuell(ctx, jetzt)
if zweiter := s.Aktuell(ctx, jetzt.Add(time.Minute)); !zweiter.Erhoben.Equal(erster.Erhoben) {
t.Error("nach einer Minute neu gezählt, erwartet den gemerkten Stand")
}
dritter := s.Aktuell(ctx, jetzt.Add(MindestAbstand+time.Second))
if dritter.Erhoben.Equal(erster.Erhoben) {
t.Fatal("nach MindestAbstand nicht neu gezählt")
}
if !dritter.VorherErhoben.Equal(erster.Erhoben) {
t.Errorf("VorherErhoben = %v, erwartet den ersten Stand %v", dritter.VorherErhoben, erster.Erhoben)
}
}

290
internal/stats/stats.go Normal file
View file

@ -0,0 +1,290 @@
// Package stats zählt die Kennzahlen aller Workspaces für die Systemseite
// zusammen (AUSBAUSTUFEN.md 5.16): Bestand, Platz, Stand des Schemas und die
// Aktivität der letzten 30 Tage.
//
// # Warum nicht bei jedem Seitenaufruf?
//
// Für die Zahlen fragt Munin jeden Workspace einzeln ab — bei 40 Workspaces
// rund 300 Abfragen. Das ist kein Problem, wenn es selten geschieht, aber
// unnötig bei jedem Neuladen der Seite. Der Sammler rechnet deshalb höchstens
// alle MinInterval neu und gibt dazwischen das gemerkte Ergebnis zurück.
// Die Seite nennt den Stand ("Stand 14:05").
//
// Gerechnet wird beim Aufruf der Seite, nicht in einer eigenen Goroutine: Wer
// die Seite nie öffnet, kostet nichts, und es gibt keinen Hintergrundlauf,
// der beim Beenden aufgeräumt werden müsste.
package stats
import (
"context"
"fmt"
"sort"
"sync"
"time"
"munin/internal/store"
"munin/internal/workspace"
)
// Days ist der Zeitraum der Aktivität. Derselbe wie in der Nutzung eines
// Projekts (store.UsageWindow) — und nicht länger, weil das Suchprotokoll
// nach 30 Tagen aufgeräumt wird. Ältere Tage stünden sonst ohne Suchen da.
const Days = store.UsageWindowDays
// MinInterval ist die Zeit, die ein Ergebnis gilt, bevor neu gerechnet
// wird.
const MinInterval = 5 * time.Minute
// Snapshot ist das Ergebnis einer Erhebung.
type Snapshot struct {
CollectedAt time.Time
Duration time.Duration // wie lange die Erhebung brauchte
Workspaces []Workspace // nach Name sortiert
Total store.Inventory
DiskUsage store.DiskUsage // aller Workspaces zusammen
// SchemaVersion: die neueste Migration, die dieses Programm kennt. Ein
// Workspace, der davon abweicht, steht mit seinem Stand in Workspace.Schema.
SchemaVersion string
Days []Tag // genau Tage Einträge, der älteste zuerst, heute zuletzt
Projects []Project // mit Aktivität, die aktivsten zuerst
Idle []Project // ohne jede Aktivität im Zeitraum, nach Workspace und Name
// WaitCount zählt, wie oft Anfragen seit dem Start auf eine freie
// Datenbankverbindung warten mussten (alle Pools zusammen), zum Zeitpunkt
// der Erhebung. PrevWaitCount und PrevCollectedAt sind dieselbe Zahl bei
// der vorigen Erhebung (0 und Nullzeitpunkt bei der ersten). Die
// Differenz zeigt, ob gerade gewartet wird — die Summe seit dem Start
// sagt das nach Tagen Laufzeit nicht mehr.
WaitCount int64
PrevWaitCount int64
PrevCollectedAt time.Time
// Errors sind Workspaces, die sich nicht lesen ließen. Ihre Zahlen fehlen
// in den Summen; die übrigen sind trotzdem da.
Errors []string
}
// Workspace sind die Kennzahlen eines Workspace.
type Workspace struct {
ID, Name string
Location string // Datei oder Schema (store.Backend.Ort)
Inventory store.Inventory
DiskUsage store.DiskUsage
Schema string // zuletzt angewandte Migration
}
// SchemaMismatch sagt, ob der Workspace auf einem anderen Stand ist als das
// Programm.
func (w Workspace) SchemaMismatch(expected string) bool { return w.Schema != expected }
// Tag ist die Aktivität eines Tages über alle Workspaces.
type Tag struct {
Date time.Time // 0 Uhr UTC
AgentWrites int
HumanWrites int
Searches int
}
// Writes sind alle Schreibvorgänge des Tages.
func (t Tag) Writes() int { return t.AgentWrites + t.HumanWrites }
// Project ist die Aktivität eines Projekts im Zeitraum.
type Project struct {
Workspace string // Name
ID string
Slug string
Name string
AgentWrites int
HumanWrites int
Searches int
}
// Writes sind alle Schreibvorgänge des Projekts im Zeitraum.
func (p Project) Writes() int { return p.AgentWrites + p.HumanWrites }
// Total ist alles zusammen — danach wird sortiert.
func (p Project) Total() int { return p.Writes() + p.Searches }
// Collector erhebt den Stand und merkt ihn sich für MinInterval.
type Collector struct {
control *store.ControlStore
workspaces *workspace.Manager
// mu schützt stand und sorgt zugleich dafür, dass bei zwei
// gleichzeitigen Aufrufen nur einer rechnet; der zweite wartet und
// bekommt dasselbe Ergebnis.
mu sync.Mutex
snapshot Snapshot
}
// NewCollector legt einen Sammler an.
func NewCollector(control *store.ControlStore, workspaces *workspace.Manager) *Collector {
return &Collector{control: control, workspaces: workspaces}
}
// Current liefert einen Stand, der höchstens MinInterval alt ist, und
// erhebt dafür nötigenfalls neu.
func (s *Collector) Current(ctx context.Context, now time.Time) Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
if !s.snapshot.CollectedAt.IsZero() && now.Sub(s.snapshot.CollectedAt) < MinInterval {
return s.snapshot
}
newer := s.collect(ctx, now)
newer.PrevWaitCount, newer.PrevCollectedAt = s.snapshot.WaitCount, s.snapshot.CollectedAt
s.snapshot = newer
return s.snapshot
}
// WaitCount zählt die Wartevorgänge aller gerade offenen Pools zusammen: der
// Kontroll-Datenbank und jedes geöffneten Workspace.
func (s *Collector) WaitCount() int64 {
total := s.control.PoolStats().WaitCount()
for _, w := range s.workspaces.OpenWorkspaces() {
total += w.Store.PoolStats().WaitCount()
}
return total
}
// ForWorkspace erhebt den Stand eines einzelnen Workspace — für den
// Überblick des Workspace-Admins (AUSBAUSTUFEN.md 5.17). Ohne Zwischenspeicher:
// Ein Workspace sind nur eine Handvoll Abfragen, und der Admin soll nach
// einer Änderung (Key angelegt) sofort den neuen Stand sehen.
func (s *Collector) ForWorkspace(ctx context.Context, w store.Workspace, now time.Time) Snapshot {
return s.collectAll(ctx, now, []store.Workspace{w})
}
// collect fragt jeden Workspace ab und zählt zusammen.
//
// Dafür werden alle Workspaces geöffnet, auch lange nicht benutzte. Das tun
// das tägliche Aufräumen und der Export (main.go) ohnehin schon beim Start;
// zusätzliche Verbindungen kostet die Erhebung also nicht.
func (s *Collector) collect(ctx context.Context, now time.Time) Snapshot {
all, err := s.control.ListWorkspaces(ctx)
if err != nil {
return Snapshot{CollectedAt: now, Errors: []string{fmt.Sprintf("Workspaces konnten nicht gelesen werden: %v", err)}}
}
return s.collectAll(ctx, now, all)
}
// collectAll zählt die gegebenen Workspaces zusammen.
func (s *Collector) collectAll(ctx context.Context, now time.Time, all []store.Workspace) Snapshot {
begin := time.Now()
snapshot := Snapshot{CollectedAt: now}
// Die Tage stehen von vornherein fest, auch die ohne jede Aktivität —
// sonst hätte das Diagramm Lücken, die man nicht sieht.
today := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
first := today.AddDate(0, 0, -(Days - 1))
tagNum := map[string]int{}
for i := 0; i < Days; i++ {
d := first.AddDate(0, 0, i)
snapshot.Days = append(snapshot.Days, Tag{Date: d})
tagNum[d.Format("2006-01-02")] = i
}
for _, w := range all {
ws, projects, err := s.collectWorkspace(ctx, w, now, first, &snapshot, tagNum)
if err != nil {
snapshot.Errors = append(snapshot.Errors, fmt.Sprintf("%s: %v", w.Name, err))
continue
}
snapshot.Workspaces = append(snapshot.Workspaces, ws)
snapshot.Total = snapshot.Total.Plus(ws.Inventory)
snapshot.DiskUsage.Data += ws.DiskUsage.Data
snapshot.DiskUsage.WAL += ws.DiskUsage.WAL
for _, p := range projects {
if p.Total() > 0 {
snapshot.Projects = append(snapshot.Projects, p)
} else {
snapshot.Idle = append(snapshot.Idle, p)
}
}
}
sort.Slice(snapshot.Workspaces, func(i, j int) bool { return snapshot.Workspaces[i].Name < snapshot.Workspaces[j].Name })
// Die aktivsten zuerst; bei Gleichstand alphabetisch, damit die
// Reihenfolge beim Neuladen nicht springt.
sort.Slice(snapshot.Projects, func(i, j int) bool {
a, b := snapshot.Projects[i], snapshot.Projects[j]
if a.Total() != b.Total() {
return a.Total() > b.Total()
}
return a.Workspace+a.Name < b.Workspace+b.Name
})
sort.Slice(snapshot.Idle, func(i, j int) bool {
a, b := snapshot.Idle[i], snapshot.Idle[j]
return a.Workspace+a.Name < b.Workspace+b.Name
})
snapshot.WaitCount = s.WaitCount()
snapshot.Duration = time.Since(begin)
return snapshot
}
// collectWorkspace sammelt die Zahlen eines Workspace. Die Aktivität je Tag
// zählt sie direkt in snapshot.Days ein; die je Projekt gibt sie zurück.
func (s *Collector) collectWorkspace(ctx context.Context, w store.Workspace, now, since time.Time,
snapshot *Snapshot, tagNum map[string]int) (Workspace, []Project, error) {
result := Workspace{ID: w.ID, Name: w.Name, Location: s.workspaces.Location(w.ID)}
opened, err := s.workspaces.Get(w.ID)
if err != nil {
return result, nil, err
}
st := opened.Store
if result.Inventory, err = st.Inventory(ctx, now); err != nil {
return result, nil, err
}
if result.DiskUsage, err = st.DiskUsage(ctx); err != nil {
return result, nil, err
}
actual, expected, err := st.SchemaVersion(ctx)
if err != nil {
return result, nil, err
}
result.Schema = actual
snapshot.SchemaVersion = expected // für alle Workspaces derselbe: er kommt aus dem Programm
// Jedes Projekt bekommt einen Eintrag, auch ohne Aktivität — gerade die
// ruhigen sind interessant.
list, err := st.ListProjects(ctx, w.ID)
if err != nil {
return result, nil, err
}
projects := make([]Project, len(list))
toID := map[string]*Project{}
for i, p := range list {
projects[i] = Project{Workspace: w.Name, ID: p.ID, Slug: p.Slug, Name: p.Name}
toID[p.ID] = &projects[i]
}
activity, err := st.Activity(ctx, since)
if err != nil {
return result, nil, err
}
for _, a := range activity {
if i, ok := tagNum[a.Tag]; ok {
snapshot.Days[i].AgentWrites += a.AgentWrites
snapshot.Days[i].HumanWrites += a.HumanWrites
snapshot.Days[i].Searches += a.Searches
}
// Zeilen eines inzwischen gelöschten Projekts zählen für den Tag,
// haben aber kein Projekt mehr, dem sie gehören.
if p := toID[a.ProjectID]; p != nil {
p.AgentWrites += a.AgentWrites
p.HumanWrites += a.HumanWrites
p.Searches += a.Searches
}
}
return result, projects, nil
}

View file

@ -0,0 +1,124 @@
package stats
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"munin/internal/store"
"munin/internal/workspace"
)
// testEnv legt eine Kontroll-Datenbank mit zwei Workspaces an, je einem
// Projekt darin.
func testEnv(t *testing.T) (*store.ControlStore, *workspace.Manager, []store.Workspace) {
t.Helper()
ctx := context.Background()
dir := t.TempDir()
wsDir := filepath.Join(dir, "workspaces")
if err := os.MkdirAll(wsDir, 0750); err != nil {
t.Fatal(err)
}
backend := store.NewSQLite(filepath.Join(dir, "control.db"), wsDir)
control, err := backend.OpenControl()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { control.Close() })
manager := workspace.NewManager(backend)
t.Cleanup(func() { manager.Close() })
var list []store.Workspace
for _, name := range []string{"Vertrieb", "Entwicklung"} {
ws, err := control.CreateWorkspace(ctx, name)
if err != nil {
t.Fatal(err)
}
opened, err := manager.Get(ws.ID)
if err != nil {
t.Fatal(err)
}
if _, err := opened.Store.CreateProject(ctx, ws.ID, "allgemein", "Allgemein "+name, ""); err != nil {
t.Fatal(err)
}
list = append(list, ws)
}
return control, manager, list
}
// TestCollect zählt über alle Workspaces zusammen, füllt jeden Tag des
// Zeitraums und trennt aktive von ruhigen Projekten.
func TestCollect(t *testing.T) {
control, manager, list := testEnv(t)
ctx := context.Background()
// Im Workspace "Vertrieb" schreibt ein Agent einen Eintrag.
opened, _ := manager.Get(list[0].ID)
projects, _ := opened.Store.ListProjects(ctx, list[0].ID)
if _, err := opened.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projects[0].ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceAgent,
}); err != nil {
t.Fatal(err)
}
opened.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: projects[0].ID, Actor: "agent", Action: store.ActionEntryWrite, Source: store.SourceMCP,
})
snapshot := NewCollector(control, manager).Current(ctx, time.Now())
if len(snapshot.Errors) > 0 {
t.Fatalf("Fehler: %v", snapshot.Errors)
}
if len(snapshot.Workspaces) != 2 || snapshot.Workspaces[0].Name != "Entwicklung" {
t.Errorf("Workspaces %+v, erwartet zwei, nach Name sortiert", snapshot.Workspaces)
}
if snapshot.Total.Projects != 2 || snapshot.Total.Entries != 1 {
t.Errorf("Summe %+v, erwartet 2 Projekte und 1 Eintrag", snapshot.Total)
}
if snapshot.DiskUsage.Data <= 0 {
t.Error("keine Belegung gezählt")
}
if snapshot.SchemaVersion == "" {
t.Error("Schemastand fehlt")
}
if len(snapshot.Days) != Days {
t.Fatalf("%d Tage, erwartet %d", len(snapshot.Days), Days)
}
today := snapshot.Days[len(snapshot.Days)-1]
if today.AgentWrites != 1 || today.Date.Format("2006-01-02") != time.Now().UTC().Format("2006-01-02") {
t.Errorf("letzter Tag %+v, erwartet heute mit einem Schreibvorgang", today)
}
if len(snapshot.Projects) != 1 || snapshot.Projects[0].Workspace != "Vertrieb" {
t.Errorf("aktive Projekte %+v, erwartet nur das im Vertrieb", snapshot.Projects)
}
if len(snapshot.Idle) != 1 || snapshot.Idle[0].Workspace != "Entwicklung" {
t.Errorf("ruhige Projekte %+v, erwartet nur das in der Entwicklung", snapshot.Idle)
}
}
// TestMinInterval: Innerhalb von MinInterval kommt der gemerkte Stand
// zurück, danach ein neuer — der sich die Wartezahl des vorigen merkt.
func TestMinInterval(t *testing.T) {
control, manager, _ := testEnv(t)
ctx := context.Background()
s := NewCollector(control, manager)
now := time.Now()
first := s.Current(ctx, now)
if second := s.Current(ctx, now.Add(time.Minute)); !second.CollectedAt.Equal(first.CollectedAt) {
t.Error("nach einer Minute neu gezählt, erwartet den gemerkten Stand")
}
third := s.Current(ctx, now.Add(MinInterval+time.Second))
if third.CollectedAt.Equal(first.CollectedAt) {
t.Fatal("nach MindestAbstand nicht neu gezählt")
}
if !third.PrevCollectedAt.Equal(first.CollectedAt) {
t.Errorf("VorherErhoben = %v, erwartet den ersten Stand %v", third.PrevCollectedAt, first.CollectedAt)
}
}

View file

@ -66,14 +66,14 @@ const (
// festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine
// Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag
// existiert ja schon, sonst gäbe es keine alte Fassung.
func EntryRestoreDetail(vonFassung int) string {
func EntryRestoreDetail(fromVersion int) string {
// fmt.Sprintf setzt die Zahl in den Text ein — %d steht für eine ganze Zahl.
return fmt.Sprintf("wiederhergestellt: Fassung %d", vonFassung)
return fmt.Sprintf("wiederhergestellt: Fassung %d", fromVersion)
}
// DetailCuratedOverridden leitet im Protokoll einen Schreibvorgang ein, mit dem
// ein Agent einen kuratierten Eintrag geändert hat — und damit die Markierung
// aufgehoben (siehe internal/mcpserver/kuratiert.go). Danach folgt die
// aufgehoben (siehe internal/mcpserver/curated.go). Danach folgt die
// Begründung des Agenten.
//
// Fachlich ist das eine Aktualisierung und zählt in der Nutzungsübersicht auch
@ -81,19 +81,19 @@ func EntryRestoreDetail(vonFassung int) string {
// auffindbar: CuratedOverrides sucht mit LIKE danach.
const DetailCuratedOverridden = "kuratiert aufgehoben"
// maxBegruendung begrenzt die Begründung im Protokoll. Sie soll einordnen,
// maxJustification begrenzt die Begründung im Protokoll. Sie soll einordnen,
// nicht den Eintrag noch einmal enthalten.
const maxBegruendung = 300
const maxJustification = 300
// CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden.
func CuratedOverrideDetail(begruendung string) string {
begruendung = strings.TrimSpace(begruendung)
func CuratedOverrideDetail(justification string) string {
justification = strings.TrimSpace(justification)
// In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in
// einem Umlaut landen.
if r := []rune(begruendung); len(r) > maxBegruendung {
begruendung = string(r[:maxBegruendung]) + "…"
if r := []rune(justification); len(r) > maxJustification {
justification = string(r[:maxJustification]) + "…"
}
return DetailCuratedOverridden + ": " + begruendung
return DetailCuratedOverridden + ": " + justification
}
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
@ -129,7 +129,7 @@ func (s *Store) LogAudit(ctx context.Context, e AuditEvent) {
e.ID = NewID()
}
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, NowString(), e.ProjectID, e.APIKeyID, e.Actor, e.Action, e.EntrySlug, e.Source, e.IP, e.Detail,
@ -184,45 +184,45 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err
// Die Bedingungen werden gesammelt und mit AND verbunden. Werte gehen
// immer als Platzhalter in die Abfrage, nie als Text.
var bedingungen []string
werte := []any{}
var conditions []string
values := []any{}
if f.ProjectID != "" {
bedingungen = append(bedingungen, `project_id = ?`)
werte = append(werte, f.ProjectID)
conditions = append(conditions, `project_id = ?`)
values = append(values, f.ProjectID)
}
if f.Action != "" {
bedingungen = append(bedingungen, `action = ?`)
werte = append(werte, f.Action)
conditions = append(conditions, `action = ?`)
values = append(values, f.Action)
}
if f.Actor != "" {
bedingungen = append(bedingungen, `actor = ?`)
werte = append(werte, f.Actor)
conditions = append(conditions, `actor = ?`)
values = append(values, f.Actor)
}
if !f.Since.IsZero() {
bedingungen = append(bedingungen, `ts >= ?`)
werte = append(werte, formatTime(f.Since))
conditions = append(conditions, `ts >= ?`)
values = append(values, formatTime(f.Since))
}
if !f.Until.IsZero() {
bedingungen = append(bedingungen, `ts < ?`)
werte = append(werte, formatTime(f.Until))
conditions = append(conditions, `ts < ?`)
values = append(values, formatTime(f.Until))
}
abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail
query := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail
FROM audit_events`
if len(bedingungen) > 0 {
abfrage += ` WHERE ` + strings.Join(bedingungen, ` AND `)
if len(conditions) > 0 {
query += ` WHERE ` + strings.Join(conditions, ` AND `)
}
abfrage += ` ORDER BY ts DESC LIMIT ?`
werte = append(werte, limit)
query += ` ORDER BY ts DESC LIMIT ?`
values = append(values, limit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var ereignisse []AuditEvent
var events []AuditEvent
for rows.Next() {
var e AuditEvent
var ts string
@ -231,39 +231,39 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, err
return nil, err
}
e.Timestamp = parseTime(ts)
ereignisse = append(ereignisse, e)
events = append(events, e)
}
return ereignisse, rows.Err()
return events, rows.Err()
}
// AuditActors nennt alle Zugangsnamen, die im Protokoll vorkommen, sortiert —
// für die Auswahlliste der Protokollseite. Mit projectID nur die eines
// Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt.
func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) {
abfrage := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''`
werte := []any{}
query := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''`
values := []any{}
if projectID != "" {
abfrage += ` AND project_id = ?`
werte = append(werte, projectID)
query += ` AND project_id = ?`
values = append(values, projectID)
}
abfrage += ` ORDER BY actor LIMIT ?`
werte = append(werte, auditActorLimit)
query += ` ORDER BY actor LIMIT ?`
values = append(values, auditActorLimit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var namen []string
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
namen = append(namen, n)
names = append(names, n)
}
return namen, rows.Err()
return names, rows.Err()
}
// auditActorLimit begrenzt die Auswahlliste der Zugänge.
@ -282,8 +282,8 @@ const auditActorLimit = 500
// keine Kür, sondern die Voraussetzung dafür, das Protokoll überhaupt führen zu dürfen.
//
// Übergeben wird die Grenze als Zeitstempel im Datenbankformat; alles davor fällt weg.
func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, aelterAls)
func (s *Store) PruneAudit(ctx context.Context, olderThan string) (int64, error) {
res, err := s.write.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, olderThan)
if err != nil {
return 0, fmt.Errorf("Audit-Log konnte nicht aufgeräumt werden: %w", err)
}
@ -293,16 +293,16 @@ func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error)
// ProjectNames gibt eine Zuordnung von Projekt-ID zu Anzeigename zurück.
// Wird in der Audit-Ansicht gebraucht, damit dort nicht rohe IDs stehen.
func (s *Store) ProjectNames(ctx context.Context, workspaceID string) (map[string]string, error) {
projekte, err := s.ListProjects(ctx, workspaceID)
projects, err := s.ListProjects(ctx, workspaceID)
if err != nil {
return nil, err
}
namen := make(map[string]string, len(projekte))
for _, p := range projekte {
namen[p.ID] = p.Name
names := make(map[string]string, len(projects))
for _, p := range projects {
names[p.ID] = p.Name
}
return namen, nil
return names, nil
}
// KeyNames gibt eine Zuordnung von Key-ID zu Key-Name zurück — analog zu
@ -312,26 +312,26 @@ func (s *Store) KeyNames(ctx context.Context, projectIDs []string) (map[string]s
return map[string]string{}, nil
}
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",")
werte := make([]any, len(projectIDs))
placeholder := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",")
values := make([]any, len(projectIDs))
for i, id := range projectIDs {
werte[i] = id
values[i] = id
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT id, name FROM api_keys WHERE project_id IN (`+platzhalter+`)`, werte...)
rows, err := s.read.QueryContext(ctx,
`SELECT id, name FROM api_keys WHERE project_id IN (`+placeholder+`)`, values...)
if err != nil {
return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
namen := make(map[string]string)
names := make(map[string]string)
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
namen[id] = name
names[id] = name
}
return namen, rows.Err()
return names, rows.Err()
}

View file

@ -17,26 +17,26 @@ import "context"
//
// In beiden Fällen bekommt jeder Workspace seinen eigenen, abgeschotteten
// Bereich mit denselben Tabellen.
// Kapazitaet beschreibt, wie viele Datenbankverbindungen Munin belegen darf.
// Die Rechnung dahinter steht bei PruefeKapazitaet (speicher_postgres.go).
type Kapazitaet struct {
Begrenzt bool // false: keine gemeinsame Grenze (SQLite)
Erlaubt int // max_connections des Servers
Reserve int // bleibt frei für psql, pg_dump, Superuser
JeWorkspace int // so viele belegt ein geöffneter Workspace höchstens
Fest int // Kontroll-Datenbank und Verwaltungsverbindung
// Capacity beschreibt, wie viele Datenbankverbindungen Munin belegen darf.
// Die Rechnung dahinter steht bei CheckCapacity (backend_postgres.go).
type Capacity struct {
Limited bool // false: keine gemeinsame Grenze (SQLite)
Allowed int // max_connections des Servers
Reserve int // bleibt frei für psql, pg_dump, Superuser
PerWorkspace int // so viele belegt ein geöffneter Workspace höchstens
Fixed int // Kontroll-Datenbank und Verwaltungsverbindung
}
// MaxWorkspaces ist die Zahl der Workspaces, die mit dieser Grenze höchstens
// gleichzeitig geöffnet sein können.
func (k Kapazitaet) MaxWorkspaces() int {
if !k.Begrenzt || k.JeWorkspace == 0 {
func (k Capacity) MaxWorkspaces() int {
if !k.Limited || k.PerWorkspace == 0 {
return 0
}
return (k.Erlaubt - k.Reserve - k.Fest) / k.JeWorkspace
return (k.Allowed - k.Reserve - k.Fixed) / k.PerWorkspace
}
type Speicher interface {
type Backend interface {
// OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den
// aktuellen Stand.
OpenControl() (*ControlStore, error)
@ -49,29 +49,29 @@ type Speicher interface {
// Der Store dazu muss vorher geschlossen sein (siehe workspace.Manager).
DeleteWorkspace(workspaceID string) error
// PruefeKapazitaet meldet einen Fehler, wenn anzahl Workspaces mehr
// CheckCapacity meldet einen Fehler, wenn anzahl Workspaces mehr
// Verbindungen bräuchten, als die Datenbank erlaubt. Bei SQLite gibt es
// diese Grenze nicht.
PruefeKapazitaet(ctx context.Context, anzahl int) error
CheckCapacity(ctx context.Context, count int) error
// Kapazitaet beschreibt die Grenze der Datenbankverbindungen — für die
// Capacity beschreibt die Grenze der Datenbankverbindungen — für die
// Systemseite. Bei SQLite gibt es keine (Begrenzt = false).
Kapazitaet(ctx context.Context) (Kapazitaet, error)
Capacity(ctx context.Context) (Capacity, error)
// Ort nennt, wo die Daten eines Workspace liegen: den Schemanamen
// Location nennt, wo die Daten eines Workspace liegen: den Schemanamen
// (PostgreSQL) bzw. die Datei (SQLite). Für die Workspace-Übersicht des
// Plattform-Admins, damit sich ein Workspace in der Datenbank wiederfinden
// lässt.
Ort(workspaceID string) string
Location(workspaceID string) string
// Hinweise nennt Auffälligkeiten des Speichers, die den Betrieb nicht
// Hints nennt Auffälligkeiten des Speichers, die den Betrieb nicht
// verhindern, aber behoben werden sollten — für die Systemseite. Leer,
// wenn alles in Ordnung ist.
Hinweise() []string
Hints() []string
// Beschreibung sagt für das Startprotokoll, wo die Daten liegen — ohne
// Description sagt für das Startprotokoll, wo die Daten liegen — ohne
// Passwort.
Beschreibung() string
Description() string
// Close gibt alles frei, was der Speicher selbst offen hält (nicht die
// geöffneten Stores — die schließt ihr jeweiliger Besitzer).

View file

@ -21,7 +21,7 @@ import (
// (max_connections, Standard 100). Würde Munin mehr öffnen, lehnte PostgreSQL
// neue Verbindungen ab und Anfragen schlügen fehl. Deshalb gelten hier feste,
// kleine Pools, und Munin rechnet vor dem Start und vor jedem neuen Workspace
// nach, ob sie reichen (PruefeKapazitaet). Hintergrund und Rechnung:
// nach, ob sie reichen (CheckCapacity). Hintergrund und Rechnung:
// AUSBAUSTUFEN.md 4.10, „Verbindungen fest begrenzt“.
//
// Kleine Pools reichen, weil eine Verbindung nur für die Dauer einer Abfrage
@ -29,35 +29,35 @@ import (
// Ist ein Pool voll, wartet die Anfrage in Go auf eine freie Verbindung (bis
// zum Ende ihres Kontexts), statt in PostgreSQL zu scheitern.
const (
// pgLeseverbindungen je Workspace (und für die Kontroll-Datenbank).
pgLeseverbindungen = 2
// pgReadConns je Workspace (und für die Kontroll-Datenbank).
pgReadConns = 2
// pgSchreibverbindungen je Workspace. Vorerst 1, also dasselbe Verhalten
// pgWriteConns je Workspace. Vorerst 1, also dasselbe Verhalten
// wie unter SQLite: Schreibvorgänge eines Workspace laufen nacheinander.
// Mehr erst, wenn die schreibenden Methoden gegen gleichzeitiges Schreiben
// abgesichert sind (AUSBAUSTUFEN.md 4.10, Schritt 4) — sonst kann z.B.
// PutEntry unter PostgreSQL Änderungen verlieren.
pgSchreibverbindungen = 1
pgWriteConns = 1
// pgVerbindungenJeWorkspace ist, was ein geöffneter Workspace höchstens
// pgConnsPerWorkspace ist, was ein geöffneter Workspace höchstens
// belegt.
pgVerbindungenJeWorkspace = pgLeseverbindungen + pgSchreibverbindungen
pgConnsPerWorkspace = pgReadConns + pgWriteConns
// pgFesteVerbindungen: die Kontroll-Datenbank (wie ein Workspace) plus
// pgFixedConns: die Kontroll-Datenbank (wie ein Workspace) plus
// eine Verwaltungsverbindung zum Anlegen und Löschen von Schemas.
pgFesteVerbindungen = pgVerbindungenJeWorkspace + 1
pgFixedConns = pgConnsPerWorkspace + 1
// pgReserve bleibt frei — für psql, Backups (pg_dump) und die
// Verbindungen, die PostgreSQL selbst für Superuser zurückhält.
pgReserve = 10
// pgLeerlauf: Nach so langer Ruhe schließt ein Pool seine Verbindungen.
// pgIdleTime: Nach so langer Ruhe schließt ein Pool seine Verbindungen.
// Ein Workspace, den gerade niemand benutzt, belegt dann keine.
pgLeerlauf = 2 * time.Minute
pgIdleTime = 2 * time.Minute
// Beim Start ist PostgreSQL vielleicht noch nicht bereit (der Container
// fährt gerade hoch). Munin versucht es so oft, mit dieser Pause dazwischen.
pgStartVersuche = 15
pgStartAttempts = 15
pgStartPause = 2 * time.Second
)
@ -68,14 +68,14 @@ const (
// Anwendungen liegen. Begründung: AUSBAUSTUFEN.md 4.10, „Schemanamen aus der
// Workspace-ID“.
const (
ControlSchema = "munin_control"
workspaceSchemaPraefix = "munin_ws_"
ControlSchema = "munin_control"
workspaceSchemaPrefix = "munin_ws_"
)
// erlaubteWorkspaceID passt auf IDs, wie NewID sie erzeugt (Kleinbuchstaben,
// validWorkspaceID passt auf IDs, wie NewID sie erzeugt (Kleinbuchstaben,
// Ziffern, Bindestrich). Alles andere wird abgelehnt, bevor es in einen
// Schemanamen wandert.
var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`)
var validWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`)
// WorkspaceSchema liefert den Schemanamen eines Workspace, z.B.
// "munin_ws_mue9bh3x_1c0e1120".
@ -83,12 +83,12 @@ var erlaubteWorkspaceID = regexp.MustCompile(`^[a-z0-9-]+$`)
// Die ID statt des Namens, weil sich Namen ändern können und freier Text sind.
// "-" wird zu "_", weil ein Bindestrich in PostgreSQL überall Anführungszeichen
// erzwingen würde. Den lesbaren Namen trägt ein Kommentar am Schema (siehe
// nachDemBenennen).
// afterRename).
func WorkspaceSchema(workspaceID string) (string, error) {
if !erlaubteWorkspaceID.MatchString(workspaceID) {
if !validWorkspaceID.MatchString(workspaceID) {
return "", fmt.Errorf("Workspace-ID %q ist als Schemaname nicht geeignet", workspaceID)
}
name := workspaceSchemaPraefix + strings.ReplaceAll(workspaceID, "-", "_")
name := workspaceSchemaPrefix + strings.ReplaceAll(workspaceID, "-", "_")
// PostgreSQL kürzt längere Namen stillschweigend auf 63 Byte. Zwei lange
// IDs könnten dann im selben Schema landen — lieber gleich ablehnen.
@ -100,7 +100,7 @@ func WorkspaceSchema(workspaceID string) (string, error) {
// ── Speicher ─────────────────────────────────────────────────────────────────
// PostgresSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigenes
// PostgresBackend legt die Kontroll-Datenbank und jeden Workspace als eigenes
// Schema in einer PostgreSQL-Datenbank ab.
//
// Jedes Schema bekommt eigene Pools, deren Verbindungen mit
@ -108,8 +108,8 @@ func WorkspaceSchema(workspaceID string) (string, error) {
// SQLite unverändert — "SELECT … FROM entries" trifft automatisch die Tabelle
// im richtigen Schema, und ein Workspace kann die Tabellen eines anderen gar
// nicht erst sehen.
type PostgresSpeicher struct {
basis *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt
type PostgresBackend struct {
base *pgx.ConnConfig // aus DATABASE_URL; je Schema kopiert und ergänzt
admin *sql.DB // eine Verbindung ohne search_path, für CREATE/DROP SCHEMA
// controlSchema ist im Betrieb immer ControlSchema. Nur Tests setzen ein
@ -117,58 +117,58 @@ type PostgresSpeicher struct {
// anfassen (siehe postgres_test.go).
controlSchema string
// hinweise sammelt beim Start, was nicht zum Abbruch reicht, aber auf die
// hints sammelt beim Start, was nicht zum Abbruch reicht, aber auf die
// Systemseite gehört (siehe Hinweise).
hinweise []string
hints []string
}
// NewPostgres verbindet sich mit der Datenbank aus url, wartet nötigenfalls,
// bis sie bereit ist, und legt das Schema der Kontroll-Datenbank an.
func NewPostgres(ctx context.Context, url string) (*PostgresSpeicher, error) {
basis, err := pgx.ParseConfig(url)
func NewPostgres(ctx context.Context, url string) (*PostgresBackend, error) {
base, err := pgx.ParseConfig(url)
if err != nil {
// pgx ersetzt das Passwort in der Fehlermeldung durch "xxxxx".
return nil, fmt.Errorf("DATABASE_URL ist ungültig: %w", err)
}
admin := stdlib.OpenDB(*basis)
admin := stdlib.OpenDB(*base)
admin.SetMaxOpenConns(1)
admin.SetConnMaxIdleTime(pgLeerlauf)
admin.SetConnMaxIdleTime(pgIdleTime)
p := &PostgresSpeicher{basis: basis, admin: admin, controlSchema: ControlSchema}
p := &PostgresBackend{base: base, admin: admin, controlSchema: ControlSchema}
if err := p.warteAufDatenbank(ctx); err != nil {
if err := p.waitForDatabase(ctx); err != nil {
admin.Close()
return nil, err
}
p.pruefeSortierung(ctx)
p.checkCollation(ctx)
if err := p.legeSchemaAn(ctx, p.controlSchema); err != nil {
if err := p.createSchema(ctx, p.controlSchema); err != nil {
admin.Close()
return nil, err
}
return p, nil
}
// warteAufDatenbank versucht mehrmals, die Datenbank zu erreichen.
// waitForDatabase versucht mehrmals, die Datenbank zu erreichen.
//
// Mit SQLite ist die Datei immer da. PostgreSQL läuft dagegen als eigener
// Dienst und ist nach einem gemeinsamen Start (docker compose up) oft ein paar
// Sekunden später bereit als Munin. Ohne Wiederholung bräche Munin dann sofort
// ab.
func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error {
var letzterFehler error
for versuch := 1; versuch <= pgStartVersuche; versuch++ {
func (p *PostgresBackend) waitForDatabase(ctx context.Context) error {
var lastErr error
for attempt := 1; attempt <= pgStartAttempts; attempt++ {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
letzterFehler = p.admin.PingContext(pingCtx)
lastErr = p.admin.PingContext(pingCtx)
cancel()
if letzterFehler == nil {
if lastErr == nil {
return nil
}
if versuch < pgStartVersuche {
log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", versuch, pgStartVersuche, letzterFehler)
if attempt < pgStartAttempts {
log.Printf("PostgreSQL ist noch nicht erreichbar (Versuch %d von %d): %v", attempt, pgStartAttempts, lastErr)
select {
case <-ctx.Done():
return ctx.Err()
@ -176,10 +176,10 @@ func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error {
}
}
}
return fmt.Errorf("PostgreSQL ist nicht erreichbar (%s): %w", p.Beschreibung(), letzterFehler)
return fmt.Errorf("PostgreSQL ist nicht erreichbar (%s): %w", p.Description(), lastErr)
}
// pruefeSortierung warnt, wenn die Datenbank nicht mit der Sortierung "C"
// checkCollation warnt, wenn die Datenbank nicht mit der Sortierung "C"
// angelegt wurde.
//
// SQLite sortiert Text Byte für Byte. PostgreSQL sortiert nach der
@ -187,62 +187,62 @@ func (p *PostgresSpeicher) warteAufDatenbank(ctx context.Context) error {
// zunächst übergangen — "a-c" stünde dann vor "ab". Listen wären anders
// sortiert als unter SQLite. Kein Grund, den Start zu verweigern, aber einer
// für einen deutlichen Hinweis.
func (p *PostgresSpeicher) pruefeSortierung(ctx context.Context) {
var sortierung string
func (p *PostgresBackend) checkCollation(ctx context.Context) {
var sortOrder string
err := p.admin.QueryRowContext(ctx,
`SELECT datcollate FROM pg_database WHERE datname = current_database()`,
).Scan(&sortierung)
).Scan(&sortOrder)
if err != nil {
log.Printf("Hinweis: Sortierung der Datenbank konnte nicht geprüft werden: %v", err)
return
}
if sortierung != "C" && sortierung != "POSIX" {
hinweis := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+
"Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortierung)
log.Printf("Warnung: %s", hinweis)
p.hinweise = append(p.hinweise, hinweis)
if sortOrder != "C" && sortOrder != "POSIX" {
hint := fmt.Sprintf("Die Datenbank sortiert nach %q statt \"C\". Listen sind dann anders sortiert als unter SQLite. "+
"Abhilfe: Datenbank mit --locale=C anlegen (siehe AUSBAUSTUFEN.md 4.10).", sortOrder)
log.Printf("Warnung: %s", hint)
p.hints = append(p.hints, hint)
}
}
// Hinweise nennt, was beim Start auffiel, etwa eine falsche Sortierung. Die
// Hints nennt, was beim Start auffiel, etwa eine falsche Sortierung. Die
// Liste entsteht einmal in NewPostgres: Die Sortierung einer Datenbank lässt
// sich nachträglich nicht ändern, ein erneutes Nachfragen brächte nichts.
func (p *PostgresSpeicher) Hinweise() []string { return p.hinweise }
func (p *PostgresBackend) Hints() []string { return p.hints }
// OpenControl öffnet die Kontroll-Datenbank im Schema munin_control.
//
// Der ControlStore bekommt den Speicher als Haken mit: Er prüft vor jedem neuen
// Workspace die Verbindungen und legt danach dessen Schema samt lesbarem Namen
// an (siehe vorDemAnlegen, nachDemBenennen).
func (p *PostgresSpeicher) OpenControl() (*ControlStore, error) {
lesen, schreiben, err := p.oeffnePools(p.controlSchema, controlMigrationsFS, "migrations_control")
// an (siehe beforeCreate, afterRename).
func (p *PostgresBackend) OpenControl() (*ControlStore, error) {
read, write, err := p.openPools(p.controlSchema, controlMigrationsFS, "migrations_control")
if err != nil {
return nil, err
}
return &ControlStore{lesen: lesen, schreiben: schreiben, haken: p}, nil
return &ControlStore{read: read, write: write, hooks: p}, nil
}
// OpenWorkspace öffnet das Schema eines Workspace und legt es an, falls es
// noch fehlt.
func (p *PostgresSpeicher) OpenWorkspace(workspaceID string) (*Store, error) {
func (p *PostgresBackend) OpenWorkspace(workspaceID string) (*Store, error) {
schema, err := WorkspaceSchema(workspaceID)
if err != nil {
return nil, err
}
if err := p.legeSchemaAn(context.Background(), schema); err != nil {
if err := p.createSchema(context.Background(), schema); err != nil {
return nil, err
}
lesen, schreiben, err := p.oeffnePools(schema, migrationsFS, "migrations")
read, write, err := p.openPools(schema, migrationsFS, "migrations")
if err != nil {
return nil, err
}
return &Store{lesen: lesen, schreiben: schreiben}, nil
return &Store{read: read, write: write}, nil
}
// DeleteWorkspace löscht das Schema eines Workspace samt aller Tabellen und
// Daten darin.
func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error {
func (p *PostgresBackend) DeleteWorkspace(workspaceID string) error {
schema, err := WorkspaceSchema(workspaceID)
if err != nil {
return err
@ -255,58 +255,58 @@ func (p *PostgresSpeicher) DeleteWorkspace(workspaceID string) error {
return nil
}
// Kapazitaet liest max_connections und nennt die Größen, mit denen
// PruefeKapazitaet rechnet.
func (p *PostgresSpeicher) Kapazitaet(ctx context.Context) (Kapazitaet, error) {
erlaubt, err := p.maxConnections(ctx)
// Capacity liest max_connections und nennt die Größen, mit denen
// CheckCapacity rechnet.
func (p *PostgresBackend) Capacity(ctx context.Context) (Capacity, error) {
allowed, err := p.maxConnections(ctx)
if err != nil {
return Kapazitaet{}, err
return Capacity{}, err
}
return Kapazitaet{
Begrenzt: true, Erlaubt: erlaubt, Reserve: pgReserve,
JeWorkspace: pgVerbindungenJeWorkspace, Fest: pgFesteVerbindungen,
return Capacity{
Limited: true, Allowed: allowed, Reserve: pgReserve,
PerWorkspace: pgConnsPerWorkspace, Fixed: pgFixedConns,
}, nil
}
// maxConnections liest max_connections des Servers.
func (p *PostgresSpeicher) maxConnections(ctx context.Context) (int, error) {
func (p *PostgresBackend) maxConnections(ctx context.Context) (int, error) {
var text string
if err := p.admin.QueryRowContext(ctx, `SHOW max_connections`).Scan(&text); err != nil {
return 0, fmt.Errorf("max_connections konnte nicht gelesen werden: %w", err)
}
erlaubt, err := strconv.Atoi(text)
allowed, err := strconv.Atoi(text)
if err != nil {
return 0, fmt.Errorf("max_connections hat einen unerwarteten Wert %q", text)
}
return erlaubt, nil
return allowed, nil
}
// PruefeKapazitaet rechnet nach, ob anzahl Workspaces mit ihren Pools unter
// CheckCapacity rechnet nach, ob count Workspaces mit ihren Pools unter
// max_connections bleiben (abzüglich Reserve).
//
// max_connections wird jedes Mal frisch gelesen: Wer es nach einer Meldung
// erhöht und PostgreSQL neu startet, soll Munin dafür nicht auch neu starten
// müssen.
func (p *PostgresSpeicher) PruefeKapazitaet(ctx context.Context, anzahl int) error {
erlaubt, err := p.maxConnections(ctx)
func (p *PostgresBackend) CheckCapacity(ctx context.Context, count int) error {
allowed, err := p.maxConnections(ctx)
if err != nil {
return err
}
bedarf := anzahl*pgVerbindungenJeWorkspace + pgFesteVerbindungen
if bedarf > erlaubt-pgReserve {
demand := count*pgConnsPerWorkspace + pgFixedConns
if demand > allowed-pgReserve {
return fmt.Errorf("zu wenige PostgreSQL-Verbindungen: %d Workspaces brauchen bis zu %d Verbindungen "+
"(%d je Workspace + %d fest), max_connections ist %d (davon %d Reserve). "+
"max_connections auf mindestens %d erhöhen",
anzahl, bedarf, pgVerbindungenJeWorkspace, pgFesteVerbindungen, erlaubt, pgReserve, bedarf+pgReserve)
count, demand, pgConnsPerWorkspace, pgFixedConns, allowed, pgReserve, demand+pgReserve)
}
return nil
}
// Ort nennt das Schema des Workspace, z.B. "munin_ws_mue9bh3x_1c0e1120".
// Location nennt das Schema des Workspace, z.B. "munin_ws_mue9bh3x_1c0e1120".
// Eine ID, die keinen gültigen Schemanamen ergibt, kann es hier nicht geben
// (OpenWorkspace hätte sie abgelehnt); falls doch, steht der Grund da.
func (p *PostgresSpeicher) Ort(workspaceID string) string {
func (p *PostgresBackend) Location(workspaceID string) string {
schema, err := WorkspaceSchema(workspaceID)
if err != nil {
return err.Error()
@ -314,30 +314,30 @@ func (p *PostgresSpeicher) Ort(workspaceID string) string {
return schema
}
// Beschreibung nennt Server und Datenbank, nie das Passwort.
func (p *PostgresSpeicher) Beschreibung() string {
// Description nennt Server und Datenbank, nie das Passwort.
func (p *PostgresBackend) Description() string {
return fmt.Sprintf("PostgreSQL — %s:%d/%s (Schemas %s, %s…)",
p.basis.Host, p.basis.Port, p.basis.Database, ControlSchema, workspaceSchemaPraefix)
p.base.Host, p.base.Port, p.base.Database, ControlSchema, workspaceSchemaPrefix)
}
// Close schließt die Verwaltungsverbindung.
func (p *PostgresSpeicher) Close() error { return p.admin.Close() }
func (p *PostgresBackend) Close() error { return p.admin.Close() }
// ── Haken für den ControlStore ───────────────────────────────────────────────
func (p *PostgresSpeicher) vorDemAnlegen(ctx context.Context, anzahl int) error {
return p.PruefeKapazitaet(ctx, anzahl)
func (p *PostgresBackend) beforeCreate(ctx context.Context, count int) error {
return p.CheckCapacity(ctx, count)
}
// nachDemBenennen legt das Schema des Workspace an (falls noch nicht da) und
// afterRename legt das Schema des Workspace an (falls noch nicht da) und
// schreibt den lesbaren Namen als Kommentar daran. In psql zeigt \dn+ ihn
// neben dem Schemanamen.
func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, name string) error {
func (p *PostgresBackend) afterRename(ctx context.Context, workspaceID, name string) error {
schema, err := WorkspaceSchema(workspaceID)
if err != nil {
return err
}
if err := p.legeSchemaAn(ctx, schema); err != nil {
if err := p.createSchema(ctx, schema); err != nil {
return err
}
@ -354,7 +354,7 @@ func (p *PostgresSpeicher) nachDemBenennen(ctx context.Context, workspaceID, nam
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) error {
func (p *PostgresBackend) createSchema(ctx context.Context, schema string) error {
if _, err := p.admin.ExecContext(ctx,
`CREATE SCHEMA IF NOT EXISTS `+pgx.Identifier{schema}.Sanitize(),
); err != nil {
@ -363,33 +363,33 @@ func (p *PostgresSpeicher) legeSchemaAn(ctx context.Context, schema string) erro
return nil
}
// oeffnePools öffnet Lese- und Schreibpool für ein Schema und bringt dessen
// openPools öffnet Lese- und Schreibpool für ein Schema und bringt dessen
// Tabellen auf den aktuellen Stand — das Gegenstück zu openSQLite.
func (p *PostgresSpeicher) oeffnePools(schema string, fsys embed.FS, migrationsDir string) (lesen, schreiben pool, err error) {
d := postgresDialekt{}
func (p *PostgresBackend) openPools(schema string, fsys embed.FS, migrationsDir string) (read, write pool, err error) {
d := postgresDialect{}
// Jede Verbindung dieser Pools startet mit search_path=<schema>. Kopie,
// damit die Grundeinstellung für die anderen Schemas unverändert bleibt.
cfg := p.basis.Copy()
cfg := p.base.Copy()
cfg.RuntimeParams["search_path"] = schema
oeffne := func(maxVerbindungen int) *sql.DB {
open := func(maxConns int) *sql.DB {
db := stdlib.OpenDB(*cfg)
db.SetMaxOpenConns(maxVerbindungen)
db.SetMaxIdleConns(maxVerbindungen)
db.SetConnMaxIdleTime(pgLeerlauf)
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(maxConns)
db.SetConnMaxIdleTime(pgIdleTime)
return db
}
schreibDB := oeffne(pgSchreibverbindungen)
schreiben = pool{db: schreibDB, d: d}
if err := runMigrations(schreiben, fsys, migrationsDir); err != nil {
schreibDB.Close()
writeDB := open(pgWriteConns)
write = pool{db: writeDB, d: d}
if err := runMigrations(write, fsys, migrationsDir); err != nil {
writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Schema %s: %w", schema, err)
}
lesen = pool{db: oeffne(pgLeseverbindungen), d: d}
return lesen, schreiben, nil
read = pool{db: open(pgReadConns), d: d}
return read, write, nil
}
// textLiteral schreibt s als PostgreSQL-Textkonstante, z.B. E'Vertrieb & Co'.

View file

@ -0,0 +1,81 @@
package store
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// SQLiteBackend legt die Kontroll-Datenbank und jeden Workspace als eigene
// SQLite-Datei ab. Das ist der Standard und der bisherige Aufbau
// (AUSBAUSTUFEN.md 4.4).
type SQLiteBackend struct {
controlPath string // z.B. "<DATA_DIR>/control.db"
workspaceDir string // z.B. "<DATA_DIR>/workspaces", muss existieren
}
// NewSQLite legt einen SQLite-Speicher an. Der Workspace-Ordner muss bereits
// existieren — das übernimmt main.go beim Start.
//
// Wer nur Workspaces braucht (etwa ein Test des workspace.Manager), kann
// controlPath leer lassen; OpenControl meldet dann einen Fehler.
func NewSQLite(controlPath, workspaceDir string) *SQLiteBackend {
return &SQLiteBackend{controlPath: controlPath, workspaceDir: workspaceDir}
}
func (s *SQLiteBackend) OpenControl() (*ControlStore, error) {
if s.controlPath == "" {
return nil, errors.New("SQLite-Speicher ohne Pfad zur Kontroll-Datenbank")
}
return OpenControl(s.controlPath)
}
func (s *SQLiteBackend) OpenWorkspace(workspaceID string) (*Store, error) {
return Open(s.workspaceFile(workspaceID))
}
// DeleteWorkspace entfernt die Workspace-Datei unwiederbringlich von der
// Platte — samt WAL/SHM-Nebendateien, die SQLite im laufenden Betrieb
// danebenlegt.
func (s *SQLiteBackend) DeleteWorkspace(workspaceID string) error {
filePath := s.workspaceFile(workspaceID)
for _, suffix := range []string{"", "-wal", "-shm"} {
if err := os.Remove(filePath + suffix); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err)
}
}
return nil
}
// CheckCapacity: Jede SQLite-Datei hat ihre eigenen Verbindungen, eine
// gemeinsame Obergrenze gibt es nicht.
func (s *SQLiteBackend) CheckCapacity(context.Context, int) error { return nil }
// Capacity: SQLite kennt keine gemeinsame Grenze.
func (s *SQLiteBackend) Capacity(context.Context) (Capacity, error) {
return Capacity{}, nil
}
// Location nennt die Datei relativ zum Datenverzeichnis, z.B.
// "workspaces/mue9bh3x-1c0e1120.db" — der volle Pfad stünde schon im
// Startprotokoll und wäre in der Tabelle nur lang.
func (s *SQLiteBackend) Location(workspaceID string) string {
return filepath.Join(filepath.Base(s.workspaceDir), workspaceID+".db")
}
func (s *SQLiteBackend) Description() string {
return fmt.Sprintf("SQLite — Workspaces: %s, Kontrolle: %s", s.workspaceDir, s.controlPath)
}
// Hints: Beim SQLite-Speicher gibt es beim Start nichts zu prüfen. Eine
// zu große WAL-Datei zeigt die Systemseite je Workspace (Store.DiskUsage).
func (s *SQLiteBackend) Hints() []string { return nil }
// Close: Der SQLite-Speicher hält selbst nichts offen.
func (s *SQLiteBackend) Close() error { return nil }
func (s *SQLiteBackend) workspaceFile(workspaceID string) string {
return filepath.Join(s.workspaceDir, workspaceID+".db")
}

View file

@ -23,57 +23,57 @@ var controlMigrationsFS embed.FS
// eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine
// Workspace-Abfrage gegen die falsche Datei läuft.
type ControlStore struct {
lesen pool
schreiben pool
read pool
write pool
// haken sind die zusätzlichen Schritte, die der Speicher beim Anlegen und
// hooks sind die zusätzlichen Schritte, die der Speicher beim Anlegen und
// Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil):
// Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei
// PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema
// samt lesbarem Namen angelegt (siehe speicher_postgres.go).
haken workspaceHaken
// samt lesbarem Namen angelegt (siehe backend_postgres.go).
hooks workspaceHooks
}
// workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken.
type workspaceHaken interface {
// vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist
// workspaceHooks wird vom Speicher gesetzt, siehe ControlStore.haken.
type workspaceHooks interface {
// beforeCreate prüft, ob ein weiterer Workspace möglich ist. anzahl ist
// die Zahl der Workspaces MIT dem neuen.
vorDemAnlegen(ctx context.Context, anzahl int) error
beforeCreate(ctx context.Context, count int) error
// nachDemBenennen läuft nach dem Anlegen und nach jedem Umbenennen.
nachDemBenennen(ctx context.Context, workspaceID, name string) error
// afterRename läuft nach dem Anlegen und nach jedem Umbenennen.
afterRename(ctx context.Context, workspaceID, name string) error
}
// OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr
// Schema auf den aktuellen Stand. Für PostgreSQL siehe Speicher.OpenControl.
// Schema auf den aktuellen Stand. Für PostgreSQL siehe Backend.OpenControl.
func OpenControl(path string) (*ControlStore, error) {
lesen, schreiben, err := openSQLite(path, controlMigrationsFS, "migrations_control")
read, write, err := openSQLite(path, controlMigrationsFS, "migrations_control")
if err != nil {
return nil, err
}
return &ControlStore{lesen: lesen, schreiben: schreiben}, nil
return &ControlStore{read: read, write: write}, nil
}
// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen).
func (c *ControlStore) Verbindungen() Verbindungen {
return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()}
// PoolStats liefert die Zahlen beider Pools (siehe Verbindungen).
func (c *ControlStore) PoolStats() PoolStats {
return PoolStats{Read: c.read.db.Stats(), Write: c.write.db.Stats()}
}
// Close schließt beide Verbindungspools.
func (c *ControlStore) Close() error {
fehlerLesen := c.lesen.Close()
if err := c.schreiben.Close(); err != nil {
readErr := c.read.Close()
if err := c.write.Close(); err != nil {
return err
}
return fehlerLesen
return readErr
}
// Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet.
// Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei,
// bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin.
func (c *ControlStore) Ping(ctx context.Context) error {
var eins int
return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins)
var one int
return c.read.QueryRowContext(ctx, `SELECT 1`).Scan(&one)
}
// ── Workspaces ───────────────────────────────────────────────────────────────
@ -90,7 +90,7 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string)
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
err := c.read.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`,
).Scan(&w.ID, &w.Name, &createdAt)
@ -107,39 +107,39 @@ func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string)
// CreateWorkspace legt einen neuen Workspace an.
func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) {
if c.haken != nil {
var bisher int
if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil {
if c.hooks != nil {
var sofar int
if err := c.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&sofar); err != nil {
return Workspace{}, fmt.Errorf("Workspaces konnten nicht gezählt werden: %w", err)
}
if err := c.haken.vorDemAnlegen(ctx, bisher+1); err != nil {
if err := c.hooks.beforeCreate(ctx, sofar+1); err != nil {
return Workspace{}, err
}
}
w := Workspace{ID: NewID(), Name: name}
jetzt := NowString()
now := NowString()
if _, err := c.schreiben.ExecContext(ctx,
if _, err := c.write.ExecContext(ctx,
`INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`,
w.ID, w.Name, jetzt,
w.ID, w.Name, now,
); err != nil {
return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err)
}
if c.haken != nil {
if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil {
if c.hooks != nil {
if err := c.hooks.afterRename(ctx, w.ID, w.Name); err != nil {
return Workspace{}, err
}
}
w.CreatedAt = parseTime(jetzt)
w.CreatedAt = parseTime(now)
return w, nil
}
// ListWorkspaces gibt alle Workspaces zurück, älteste zuerst.
func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) {
rows, err := c.lesen.QueryContext(ctx,
rows, err := c.read.QueryContext(ctx,
`SELECT id, name, created_at FROM workspaces ORDER BY created_at`,
)
if err != nil {
@ -165,7 +165,7 @@ func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace,
var w Workspace
var createdAt string
err := c.lesen.QueryRowContext(ctx,
err := c.read.QueryRowContext(ctx,
`SELECT id, name, created_at FROM workspaces WHERE id = ?`, id,
).Scan(&w.ID, &w.Name, &createdAt)
@ -182,22 +182,22 @@ func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace,
// UpdateWorkspace benennt einen Workspace um.
func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error {
res, err := c.schreiben.ExecContext(ctx,
res, err := c.write.ExecContext(ctx,
`UPDATE workspaces SET name = ? WHERE id = ?`, name, id,
)
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
betroffen, err := res.RowsAffected()
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err)
}
if betroffen == 0 {
if affected == 0 {
return ErrNotFound
}
if c.haken != nil {
return c.haken.nachDemBenennen(ctx, id, name)
if c.hooks != nil {
return c.hooks.afterRename(ctx, id, name)
}
return nil
}
@ -211,15 +211,15 @@ func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) err
// wenn beides passiert ist, ist der Workspace wirklich weg (siehe
// handleWorkspaceDelete in internal/web/handlers_workspaces.go).
func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error {
res, err := c.schreiben.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
res, err := c.write.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
betroffen, err := res.RowsAffected()
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err)
}
if betroffen == 0 {
if affected == 0 {
return ErrNotFound
}
return nil
@ -232,7 +232,7 @@ func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error {
// diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet
// werden, bevor dessen Datei bekannt ist.
func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error {
_, err := c.schreiben.ExecContext(ctx,
_, err := c.write.ExecContext(ctx,
`INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`,
hash, workspaceID, NowString(),
)
@ -245,7 +245,7 @@ func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) e
// RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im
// selben Zug wie Store.RevokeAPIKey.
func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error {
_, err := c.schreiben.ExecContext(ctx,
_, err := c.write.ExecContext(ctx,
`DELETE FROM key_index WHERE hash = ?`, hash,
)
if err != nil {
@ -261,7 +261,7 @@ func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error {
// WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt
// angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft.
func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) {
err = c.lesen.QueryRowContext(ctx,
err = c.read.QueryRowContext(ctx,
`SELECT workspace_id FROM key_index WHERE hash = ?`, hash,
).Scan(&workspaceID)

View file

@ -11,7 +11,7 @@ import (
// postgres_test.go).
func testControl(t *testing.T) *ControlStore {
t.Helper()
if pgTestAktiv() {
if pgTestEnabled() {
return testPostgresControl(t)
}
@ -27,16 +27,16 @@ func TestEnsureDefaultWorkspaceIsStable(t *testing.T) {
c := testControl(t)
ctx := context.Background()
erst, err := c.EnsureDefaultWorkspace(ctx, "Intern")
first, err := c.EnsureDefaultWorkspace(ctx, "Intern")
if err != nil {
t.Fatal(err)
}
zweit, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name")
second, err := c.EnsureDefaultWorkspace(ctx, "Anderer Name")
if err != nil {
t.Fatal(err)
}
if erst.ID != zweit.ID {
if first.ID != second.ID {
t.Error("EnsureDefaultWorkspace hat einen zweiten Workspace angelegt statt den bestehenden zu nehmen")
}
}
@ -54,12 +54,12 @@ func TestKeyIndexRouting(t *testing.T) {
t.Fatal(err)
}
gefunden, err := c.ResolveKeyWorkspace(ctx, "hash-1")
found, err := c.ResolveKeyWorkspace(ctx, "hash-1")
if err != nil {
t.Fatal(err)
}
if gefunden != ws.ID {
t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", gefunden, ws.ID)
if found != ws.ID {
t.Errorf("ResolveKeyWorkspace = %q, erwartet %q", found, ws.ID)
}
if err := c.RemoveKeyIndex(ctx, "hash-1"); err != nil {
@ -91,12 +91,12 @@ func TestListWorkspaces(t *testing.T) {
t.Errorf("ListWorkspaces = %+v, erwartet [%q, %q] in Anlegereihenfolge", ws, a.ID, b.ID)
}
gelesen, err := c.WorkspaceByID(ctx, a.ID)
read, err := c.WorkspaceByID(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Team A" {
t.Errorf("WorkspaceByID.Name = %q, erwartet %q", gelesen.Name, "Team A")
if read.Name != "Team A" {
t.Errorf("WorkspaceByID.Name = %q, erwartet %q", read.Name, "Team A")
}
if _, err := c.WorkspaceByID(ctx, "unbekannt"); err != ErrNotFound {
@ -129,9 +129,9 @@ func TestWorkspaceAdmins(t *testing.T) {
t.Fatal(err)
}
gefunden, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1")
if err != nil || gefunden.ID != admin.ID || gefunden.WorkspaceID != wsA.ID {
t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", gefunden, err)
found, err := c.ResolveWorkspaceAdminHash(ctx, "hash-1")
if err != nil || found.ID != admin.ID || found.WorkspaceID != wsA.ID {
t.Fatalf("ResolveWorkspaceAdminHash = %+v, %v", found, err)
}
if err := c.RevokeWorkspaceAdmin(ctx, wsB.ID, admin.ID); err != ErrNotFound {
@ -146,9 +146,9 @@ func TestWorkspaceAdmins(t *testing.T) {
}
// Die Zeile bleibt für die Verwaltung und das Protokoll erhalten.
gelesen, err := c.WorkspaceAdminByID(ctx, admin.ID)
if err != nil || !gelesen.Revoked() {
t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", gelesen, err)
read, err := c.WorkspaceAdminByID(ctx, admin.ID)
if err != nil || !read.Revoked() {
t.Errorf("WorkspaceAdminByID = %+v, %v — erwartet zurückgezogen", read, err)
}
}

View file

@ -6,7 +6,7 @@ import (
"strings"
)
// dialekt beschreibt, worin sich SQLite und PostgreSQL unterscheiden.
// dialect beschreibt, worin sich SQLite und PostgreSQL unterscheiden.
//
// # Warum so klein?
//
@ -15,41 +15,41 @@ import (
// nur, was sich wirklich unterscheidet. Jede Methode, die hier dazukommt, muss
// für beide Datenbanken umgesetzt werden — fehlt eine, baut das Programm nicht.
// Der Compiler passt also auf, dass keine Seite vergessen wird.
type dialekt interface {
type dialect interface {
// rebind wandelt die Platzhalter einer Abfrage in die Schreibweise der
// Datenbank um. Geschrieben werden alle Abfragen mit "?" (SQLite);
// PostgreSQL erwartet "$1, $2, …".
rebind(abfrage string) string
rebind(query string) string
// migrationsUnterordner nennt den Ordner innerhalb von migrations/ bzw.
// migrationsSubdir nennt den Ordner innerhalb von migrations/ bzw.
// migrations_control/, aus dem die Migrationen dieser Datenbank kommen.
migrationsUnterordner() string
migrationsSubdir() string
// belegung misst, wie viel Platz ein Workspace belegt: bei SQLite die
// diskUsage misst, wie viel Platz ein Workspace belegt: bei SQLite die
// Datei samt WAL, bei PostgreSQL die Tabellen des Schemas. Die
// Umsetzungen stehen in systemstand.go.
belegung(ctx context.Context, p pool) (Belegung, error)
// Umsetzungen stehen in sysinfo.go.
diskUsage(ctx context.Context, p pool) (DiskUsage, error)
}
// ── SQLite ───────────────────────────────────────────────────────────────────
type sqliteDialekt struct{}
type sqliteDialect struct{}
// rebind ist bei SQLite nichts zu tun: Die Abfragen sind bereits in der
// SQLite-Schreibweise geschrieben.
func (sqliteDialekt) rebind(abfrage string) string { return abfrage }
func (sqliteDialect) rebind(query string) string { return query }
func (sqliteDialekt) migrationsUnterordner() string { return "sqlite" }
func (sqliteDialect) migrationsSubdir() string { return "sqlite" }
// ── PostgreSQL ───────────────────────────────────────────────────────────────
type postgresDialekt struct{}
type postgresDialect struct{}
func (postgresDialekt) rebind(abfrage string) string { return platzhalterNummerieren(abfrage) }
func (postgresDialect) rebind(query string) string { return numberPlaceholders(query) }
func (postgresDialekt) migrationsUnterordner() string { return "postgres" }
func (postgresDialect) migrationsSubdir() string { return "postgres" }
// platzhalterNummerieren ersetzt jedes "?" durch "$1", "$2", … in der
// numberPlaceholders ersetzt jedes "?" durch "$1", "$2", … in der
// Reihenfolge des Auftretens.
//
// Ein "?" innerhalb eines Textes in Hochkommas ('Wie geht das?'), eines
@ -60,55 +60,55 @@ func (postgresDialekt) migrationsUnterordner() string { return "postgres" }
// Ein verdoppeltes Hochkomma (so schreibt SQL ein Hochkomma innerhalb eines
// Textes) braucht keine Sonderbehandlung: Das erste beendet den Text, das
// zweite beginnt sofort einen neuen — das Ergebnis ist dasselbe.
func platzhalterNummerieren(abfrage string) string {
func numberPlaceholders(query string) string {
// Schneller Weg: Die meisten Abfragen sind kurz, aber ohne "?" gibt es
// gar nichts zu tun.
if !strings.Contains(abfrage, "?") {
return abfrage
if !strings.Contains(query, "?") {
return query
}
var b strings.Builder
b.Grow(len(abfrage) + 16)
b.Grow(len(query) + 16)
nummer := 0
for i := 0; i < len(abfrage); i++ {
c := abfrage[i]
number := 0
for i := 0; i < len(query); i++ {
c := query[i]
switch {
case c == '\'' || c == '"':
// Text bzw. Bezeichner: bis zum passenden Gegenstück unverändert
// übernehmen.
ende := strings.IndexByte(abfrage[i+1:], c)
if ende < 0 {
b.WriteString(abfrage[i:])
end := strings.IndexByte(query[i+1:], c)
if end < 0 {
b.WriteString(query[i:])
return b.String()
}
b.WriteString(abfrage[i : i+1+ende+1])
i += ende + 1
b.WriteString(query[i : i+1+end+1])
i += end + 1
case c == '-' && i+1 < len(abfrage) && abfrage[i+1] == '-':
case c == '-' && i+1 < len(query) && query[i+1] == '-':
// Kommentar bis zum Zeilenende.
ende := strings.IndexByte(abfrage[i:], '\n')
if ende < 0 {
b.WriteString(abfrage[i:])
end := strings.IndexByte(query[i:], '\n')
if end < 0 {
b.WriteString(query[i:])
return b.String()
}
b.WriteString(abfrage[i : i+ende])
i += ende - 1
b.WriteString(query[i : i+end])
i += end - 1
case c == '/' && i+1 < len(abfrage) && abfrage[i+1] == '*':
case c == '/' && i+1 < len(query) && query[i+1] == '*':
// Blockkommentar bis "*/".
ende := strings.Index(abfrage[i+2:], "*/")
if ende < 0 {
b.WriteString(abfrage[i:])
end := strings.Index(query[i+2:], "*/")
if end < 0 {
b.WriteString(query[i:])
return b.String()
}
b.WriteString(abfrage[i : i+2+ende+2])
i += 2 + ende + 1
b.WriteString(query[i : i+2+end+2])
i += 2 + end + 1
case c == '?':
nummer++
number++
b.WriteByte('$')
b.WriteString(strconv.Itoa(nummer))
b.WriteString(strconv.Itoa(number))
default:
b.WriteByte(c)

View file

@ -9,9 +9,9 @@ import (
// Diese Tests laufen immer, auch ohne PostgreSQL. Die Tests gegen eine echte
// PostgreSQL-Datenbank stehen in postgres_test.go.
func TestPlatzhalterNummerieren(t *testing.T) {
faelle := []struct {
name, ein, aus string
func TestNumberPlaceholders(t *testing.T) {
cases := []struct {
name, in, out string
}{
{"ohne Platzhalter", `SELECT 1`, `SELECT 1`},
{"der Reihe nach", `SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`},
@ -25,56 +25,56 @@ func TestPlatzhalterNummerieren(t *testing.T) {
{"Kommentar am Ende ohne Zeilenumbruch", `WHERE a = ? -- fertig?`, `WHERE a = $1 -- fertig?`},
{"zweistellig", `(?,?,?,?,?,?,?,?,?,?,?)`, `($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
if got := platzhalterNummerieren(f.ein); got != f.aus {
t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.ein, got, f.aus)
if got := numberPlaceholders(f.in); got != f.out {
t.Errorf("\nein: %s\nist: %s\nsoll: %s", f.in, got, f.out)
}
})
}
}
func TestSQLiteRebindLaesstAbfrageUnveraendert(t *testing.T) {
abfrage := `SELECT a FROM t WHERE x = ?`
if got := (sqliteDialekt{}).rebind(abfrage); got != abfrage {
func TestSQLiteRebindLeavesQueryUnchanged(t *testing.T) {
query := `SELECT a FROM t WHERE x = ?`
if got := (sqliteDialect{}).rebind(query); got != query {
t.Errorf("SQLite hat die Abfrage verändert: %s", got)
}
}
// TestMigrationsordnerGleich prüft, dass sqlite/ und postgres/ dieselben
// TestMigrationDirsMatch prüft, dass sqlite/ und postgres/ dieselben
// Migrationen enthalten. Eine Migration, die nur in einem Ordner liegt, würde
// die beiden Datenbanken unbemerkt auseinanderlaufen lassen — siehe
// AUSBAUSTUFEN.md 4.10.
//
// Ob die Migrationen auch dasselbe SCHEMA ergeben, prüft TestSchemaGleich
// Ob die Migrationen auch dasselbe SCHEMA ergeben, prüft TestSchemaMatches
// (braucht PostgreSQL).
func TestMigrationsordnerGleich(t *testing.T) {
for _, quelle := range []struct {
func TestMigrationDirsMatch(t *testing.T) {
for _, source := range []struct {
fsys fs.FS
dir string
}{
{migrationsFS, "migrations"},
{controlMigrationsFS, "migrations_control"},
} {
dateien := func(unterordner string) []string {
eintraege, err := fs.ReadDir(quelle.fsys, quelle.dir+"/"+unterordner)
files := func(subdir string) []string {
entries, err := fs.ReadDir(source.fsys, source.dir+"/"+subdir)
if err != nil {
t.Fatalf("%s/%s: %v", quelle.dir, unterordner, err)
t.Fatalf("%s/%s: %v", source.dir, subdir, err)
}
var namen []string
for _, e := range eintraege {
namen = append(namen, e.Name())
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
return namen
return names
}
sqlite := dateien((sqliteDialekt{}).migrationsUnterordner())
postgres := dateien((postgresDialekt{}).migrationsUnterordner())
sqlite := files((sqliteDialect{}).migrationsSubdir())
postgres := files((postgresDialect{}).migrationsSubdir())
if len(sqlite) == 0 {
t.Errorf("%s: keine Migrationen gefunden", quelle.dir)
t.Errorf("%s: keine Migrationen gefunden", source.dir)
}
if !slices.Equal(sqlite, postgres) {
t.Errorf("%s: Ordner unterscheiden sich\nsqlite: %v\npostgres: %v", quelle.dir, sqlite, postgres)
t.Errorf("%s: Ordner unterscheiden sich\nsqlite: %v\npostgres: %v", source.dir, sqlite, postgres)
}
}
}
@ -94,24 +94,24 @@ func TestWorkspaceSchema(t *testing.T) {
}
// Alles, was nicht wie eine ID aussieht, darf nie in einen Schemanamen.
for _, kaputt := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} {
if _, err := WorkspaceSchema(kaputt); err == nil {
t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", kaputt)
for _, broken := range []string{"", "Groß", "a b", "a;DROP", `a"b`, "../x", "ä"} {
if _, err := WorkspaceSchema(broken); err == nil {
t.Errorf("WorkspaceSchema(%q) hätte abgelehnt werden müssen", broken)
}
}
// PostgreSQL würde längere Namen stillschweigend kürzen.
lang := ""
for len(lang) < 60 {
lang += "abcdefghij"
long := ""
for len(long) < 60 {
long += "abcdefghij"
}
if _, err := WorkspaceSchema(lang); err == nil {
if _, err := WorkspaceSchema(long); err == nil {
t.Error("zu lange ID wurde nicht abgelehnt")
}
}
func TestTextLiteral(t *testing.T) {
faelle := map[string]string{
cases := map[string]string{
"Vertrieb": `E'Vertrieb'`,
"O'Brien": `E'O''Brien'`,
`C:\temp`: `E'C:\\temp'`,
@ -119,9 +119,9 @@ func TestTextLiteral(t *testing.T) {
"mit\x00Null": `E'mitNull'`,
"Ärger & Öl": `E'Ärger & Öl'`,
}
for ein, soll := range faelle {
if got := textLiteral(ein); got != soll {
t.Errorf("textLiteral(%q) = %s, erwartet %s", ein, got, soll)
for in, expected := range cases {
if got := textLiteral(in); got != expected {
t.Errorf("textLiteral(%q) = %s, erwartet %s", in, got, expected)
}
}
}

View file

@ -48,15 +48,15 @@ type EntryWrite struct {
UpdatedAt time.Time
}
// nichtImPapierkorb gehört in JEDE lesende Abfrage auf entries (siehe
// migrations/005_papierkorb.sql). Als Konstante, damit sich alle Stellen mit
// notInTrash gehört in JEDE lesende Abfrage auf entries (siehe
// migrations/005_trash.sql). Als Konstante, damit sich alle Stellen mit
// einer Suche finden lassen — und damit eine neue Abfrage ohne diese Bedingung
// beim Lesen auffällt. TestPapierkorbUnsichtbar (papierkorb_test.go) prüft
// beim Lesen auffällt. TestTrashInvisible (trash_test.go) prüft
// jeden lesenden Weg.
//
// Wo die Tabelle in einer Verknüpfung den Kurznamen "e" trägt, steht die
// Bedingung ausgeschrieben als "e.deleted_at IS NULL".
const nichtImPapierkorb = `deleted_at IS NULL`
const notInTrash = `deleted_at IS NULL`
// entryColumns steht in jeder Eintrags-Abfrage.
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at`
@ -86,20 +86,20 @@ func scanEntry(scan func(...any) error) (Entry, error) {
// verschiedene Schlagworte in der Datenbank — und keine Filterung würde je
// vollständig treffen.
func NormalizeTags(tags []string) []string {
gesehen := make(map[string]bool, len(tags))
var sauber []string
seen := make(map[string]bool, len(tags))
var clean []string
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || gesehen[t] {
if t == "" || seen[t] {
continue
}
gesehen[t] = true
sauber = append(sauber, t)
seen[t] = true
clean = append(clean, t)
}
sort.Strings(sauber)
return sauber
sort.Strings(clean)
return clean
}
// PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet.
@ -123,14 +123,14 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
tags := NormalizeTags(w.Tags)
links := ExtractLinks(w.Content)
jetzt := NowString()
now := NowString()
var ergebnis Entry
var result Entry
err := s.tx(ctx, func(tx *transaktion) error {
err := s.tx(ctx, func(tx *transaction) error {
// Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden.
//
// Bewusst OHNE nichtImPapierkorb: Ein Eintrag im Papierkorb belegt seinen
// Bewusst OHNE notInTrash: Ein Eintrag im Papierkorb belegt seinen
// Bezeichner weiter. Wer ihn neu beschreibt, holt ihn zurück (siehe
// unten) — statt an einer UNIQUE-Verletzung zu scheitern oder
// stillschweigend einen zweiten Eintrag daneben zu legen.
@ -140,12 +140,12 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
// "liegt im Papierkorb" (Zeitpunkt gesetzt) von "normal" (NULL).
var id, createdAt string
var version int
var geloeschtAm sql.NullString
var deletedAt sql.NullString
err := tx.QueryRowContext(ctx,
`SELECT id, version, created_at, deleted_at FROM entries WHERE project_id = ? AND slug = ?`,
w.ProjectID, w.Slug,
).Scan(&id, &version, &createdAt, &geloeschtAm)
imPapierkorb := geloeschtAm.Valid
).Scan(&id, &version, &createdAt, &deletedAt)
inTrash := deletedAt.Valid
switch {
case errors.Is(err, sql.ErrNoRows):
@ -160,19 +160,19 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
id = NewID()
version = 1
createdAt = jetzt
createdAt = now
if !w.CreatedAt.IsZero() {
createdAt = formatTime(w.CreatedAt)
}
if !w.UpdatedAt.IsZero() {
jetzt = formatTime(w.UpdatedAt)
now = formatTime(w.UpdatedAt)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO entries (id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, w.ProjectID, w.Slug, w.Title, w.Content, w.Source, w.Author,
boolToInt(w.Curated), version, createdAt, jetzt,
boolToInt(w.Curated), version, createdAt, now,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err)
}
@ -189,7 +189,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
// Agent "forget" aufrief. Das ist ein Konflikt, genau wie beim
// zwischenzeitlich geänderten Eintrag: Die Löschung soll nicht
// unbemerkt rückgängig gemacht werden.
if imPapierkorb && w.ExpectedVersion > 0 {
if inTrash && w.ExpectedVersion > 0 {
return fmt.Errorf("%w: der Eintrag wurde inzwischen gelöscht und liegt im Papierkorb", ErrConflict)
}
if w.ExpectedVersion > 0 && w.ExpectedVersion != version {
@ -213,7 +213,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?,
deleted_at = NULL, deleted_by = ''
WHERE id = ?`,
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id,
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, now, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err)
}
@ -229,7 +229,7 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
return err
}
ergebnis = Entry{
result = Entry{
ID: id,
ProjectID: w.ProjectID,
Slug: w.Slug,
@ -241,9 +241,9 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
Curated: w.Curated,
Version: version,
CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(jetzt),
UpdatedAt: parseTime(now),
RestoredFromTrash: imPapierkorb,
RestoredFromTrash: inTrash,
}
return nil
})
@ -251,17 +251,17 @@ func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
if err != nil {
return Entry{}, err
}
return ergebnis, nil
return result, nil
}
// snapshotEntryVersion sichert den aktuellen Stand eines Eintrags in
// entry_versions, bevor er überschrieben wird — die einzige Stelle, an der
// diese Tabelle beschrieben wird (siehe migrations/004_aenderungshistorie.sql).
// diese Tabelle beschrieben wird (siehe migrations/004_change_history.sql).
//
// Läuft immer als Teil der Transaktion, die auch die eigentliche Änderung
// vornimmt: Entweder werden alte Fassung und neue Fassung beide geschrieben,
// oder keine von beiden.
func snapshotEntryVersion(ctx context.Context, tx *transaktion, projectID, entryID string) error {
func snapshotEntryVersion(ctx context.Context, tx *transaction, projectID, entryID string) error {
var version int
var title, content, source, author string
var curated int
@ -326,7 +326,7 @@ const entryVersionColumns = `id, entry_id, version, title, content, tags, author
// EntryVersions gibt die abgelösten Fassungen eines Eintrags zurück, neueste
// zuerst. Die aktuelle Fassung steht nicht darin — die liefert EntryBySlug.
func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersion, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? ORDER BY version DESC`,
entryID,
)
@ -335,20 +335,20 @@ func (s *Store) EntryVersions(ctx context.Context, entryID string) ([]EntryVersi
}
defer rows.Close()
var versionen []EntryVersion
var versions []EntryVersion
for rows.Next() {
v, err := scanEntryVersion(rows.Scan)
if err != nil {
return nil, err
}
versionen = append(versionen, v)
versions = append(versions, v)
}
return versionen, rows.Err()
return versions, rows.Err()
}
// EntryVersionByNumber liest eine einzelne abgelöste Fassung.
func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, version int) (EntryVersion, error) {
row := s.lesen.QueryRowContext(ctx,
row := s.read.QueryRowContext(ctx,
`SELECT `+entryVersionColumns+` FROM entry_versions WHERE entry_id = ? AND version = ?`,
entryID, version,
)
@ -365,11 +365,11 @@ func (s *Store) EntryVersionByNumber(ctx context.Context, entryID string, versio
// replaceTags ersetzt die Schlagworte eines Eintrags.
//
// project_id wird mitgeschrieben, obwohl es schon am Eintrag steht — siehe die
// Begründung in migrations/002_projekt_in_nebentabellen.sql. Diese Funktion und
// Begründung in migrations/002_project_in_child_tables.sql. Diese Funktion und
// replaceLinks sind die einzigen Schreibwege in diese Tabellen, und beide laufen
// in derselben Transaktion wie der Eintrag. Die Redundanz kann deshalb nicht
// auseinanderlaufen.
func replaceTags(ctx context.Context, tx *transaktion, projectID, entryID string, tags []string) error {
func replaceTags(ctx context.Context, tx *transaction, projectID, entryID string, tags []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_tags WHERE entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Schlagworte konnten nicht ersetzt werden: %w", err)
}
@ -384,16 +384,16 @@ func replaceTags(ctx context.Context, tx *transaktion, projectID, entryID string
}
// replaceLinks ersetzt die ausgehenden Verweise eines Eintrags.
func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID string, links []string) error {
func replaceLinks(ctx context.Context, tx *transaction, projectID, entryID string, links []string) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM entry_links WHERE from_entry_id = ?`, entryID); err != nil {
return fmt.Errorf("Verweise konnten nicht ersetzt werden: %w", err)
}
for _, ziel := range links {
for _, target := range links {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`,
entryID, projectID, ziel,
entryID, projectID, target,
); err != nil {
return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", ziel, err)
return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", target, err)
}
}
return nil
@ -401,8 +401,8 @@ func replaceLinks(ctx context.Context, tx *transaktion, projectID, entryID strin
// EntryBySlug liest einen Eintrag samt Schlagworten.
func (s *Store) EntryBySlug(ctx context.Context, projectID, slug string) (Entry, error) {
row := s.lesen.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
row := s.read.QueryRowContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug,
)
@ -445,48 +445,48 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter
// Die Abfrage wird stückweise zusammengesetzt. Wichtig: Werte gehen immer als
// Parameter (?) in die Abfrage, nie per String-Verkettung — sonst wäre das
// eine SQL-Injection-Lücke.
abfrage := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + nichtImPapierkorb
werte := []any{projectID}
query := `SELECT ` + entryColumns + ` FROM entries WHERE project_id = ? AND ` + notInTrash
values := []any{projectID}
if f.Tag != "" {
// project_id gehört mit in die Unterabfrage, damit der Index
// (project_id, tag) greift statt nur über das Schlagwort zu suchen.
abfrage += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)`
werte = append(werte, projectID, strings.ToLower(strings.TrimSpace(f.Tag)))
query += ` AND id IN (SELECT entry_id FROM entry_tags WHERE project_id = ? AND tag = ?)`
values = append(values, projectID, strings.ToLower(strings.TrimSpace(f.Tag)))
}
if f.Curated {
abfrage += ` AND curated = 1`
query += ` AND curated = 1`
}
if !f.ChangedBefore.IsZero() {
// Textvergleich auf RFC3339-Zeitstempeln, wie überall im Store (siehe
// timeFormat): Das sortiert richtig und braucht keine Datumsfunktion
// der Datenbank.
abfrage += ` AND updated_at < ?`
werte = append(werte, formatTime(f.ChangedBefore))
query += ` AND updated_at < ?`
values = append(values, formatTime(f.ChangedBefore))
}
abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
werte = append(werte, limit, f.Offset)
query += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
values = append(values, limit, f.Offset)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var eintraege []Entry
var entries []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return eintraege, s.attachTagsToSlice(ctx, eintraege)
return entries, s.attachTagsToSlice(ctx, entries)
}
// AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des
@ -494,8 +494,8 @@ func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter
// nicht dazu: Sie sollen weder gefunden werden noch im Export wieder
// auftauchen.
func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+nichtImPapierkorb+` ORDER BY slug`,
rows, err := s.read.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? AND `+notInTrash+` ORDER BY slug`,
projectID,
)
if err != nil {
@ -503,26 +503,26 @@ func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, erro
}
defer rows.Close()
var eintraege []Entry
var entries []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return eintraege, s.attachTagsToSlice(ctx, eintraege)
return entries, s.attachTagsToSlice(ctx, entries)
}
// CountEntries zählt die Einträge eines Projekts (ohne Papierkorb).
func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error) {
var n int
err := s.lesen.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID,
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
@ -533,11 +533,11 @@ func (s *Store) CountEntries(ctx context.Context, projectID string) (int, error)
// CountStaleEntries zählt die Einträge eines Projekts, die seit StaleAfter
// nicht geändert wurden (siehe Entry.Stale). Für die Weboberfläche: Liste und
// Nutzungsseite nennen die Zahl, damit ein Team gezielt aufräumen kann.
func (s *Store) CountStaleEntries(ctx context.Context, projectID string, jetzt time.Time) (int, error) {
func (s *Store) CountStaleEntries(ctx context.Context, projectID string, now time.Time) (int, error) {
var n int
err := s.lesen.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+nichtImPapierkorb,
projectID, formatTime(StaleCutoff(jetzt)),
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND updated_at < ? AND `+notInTrash,
projectID, formatTime(StaleCutoff(now)),
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("veraltete Einträge konnten nicht gezählt werden: %w", err)
@ -558,9 +558,9 @@ func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, err
// Die Schlagworte gelöschter Einträge bleiben in entry_tags stehen — sie
// werden beim Wiederherstellen gebraucht. Damit sie hier nicht mitgezählt
// werden, schließt die Unterabfrage die Einträge im Papierkorb aus. Die
// läuft über den kleinen Teilindex idx_entries_papierkorb (migrations/005)
// läuft über den kleinen Teilindex idx_entries_trash (migrations/005)
// und kostet deshalb praktisch nichts, egal wie groß das Projekt ist.
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT tag, COUNT(*) AS anzahl
FROM entry_tags
WHERE project_id = ?
@ -592,12 +592,12 @@ type TagCount struct {
}
// attachTagsToSlice lädt die Schlagworte für eine Ergebnisliste nach.
func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error {
zeiger := make([]*Entry, len(eintraege))
for i := range eintraege {
zeiger[i] = &eintraege[i]
func (s *Store) attachTagsToSlice(ctx context.Context, entries []Entry) error {
cursor := make([]*Entry, len(entries))
for i := range entries {
cursor[i] = &entries[i]
}
return s.attachTags(ctx, zeiger)
return s.attachTags(ctx, cursor)
}
// attachTags lädt die Schlagworte mehrerer Einträge in EINER Abfrage nach.
@ -605,24 +605,24 @@ func (s *Store) attachTagsToSlice(ctx context.Context, eintraege []Entry) error
// Die naheliegende Lösung wäre, pro Eintrag einmal nachzufragen — das ist das
// klassische "N+1-Problem": bei 50 Einträgen 51 Abfragen. Stattdessen wird eine
// Abfrage mit einer IN-Liste gestellt und das Ergebnis in Go zugeordnet.
func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
if len(eintraege) == 0 {
func (s *Store) attachTags(ctx context.Context, entries []*Entry) error {
if len(entries) == 0 {
return nil
}
// Platzhalter "?, ?, ?" in der passenden Anzahl bauen.
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(eintraege)), ",")
werte := make([]any, len(eintraege))
nachID := make(map[string]*Entry, len(eintraege))
placeholder := strings.TrimSuffix(strings.Repeat("?,", len(entries)), ",")
values := make([]any, len(entries))
toID := make(map[string]*Entry, len(entries))
for i, e := range eintraege {
werte[i] = e.ID
nachID[e.ID] = e
for i, e := range entries {
values[i] = e.ID
toID[e.ID] = e
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+platzhalter+`) ORDER BY tag`,
werte...,
rows, err := s.read.QueryContext(ctx,
`SELECT entry_id, tag FROM entry_tags WHERE entry_id IN (`+placeholder+`) ORDER BY tag`,
values...,
)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err)
@ -634,7 +634,7 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
if err := rows.Scan(&entryID, &tag); err != nil {
return err
}
if e, ok := nachID[entryID]; ok {
if e, ok := toID[entryID]; ok {
e.Tags = append(e.Tags, tag)
}
}
@ -656,20 +656,20 @@ func (s *Store) attachTags(ctx context.Context, eintraege []*Entry) error {
func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs, add, remove []string) ([]Entry, error) {
add = NormalizeTags(add)
remove = NormalizeTags(remove)
entferneMenge := make(map[string]bool, len(remove))
removeSet := make(map[string]bool, len(remove))
for _, t := range remove {
entferneMenge[t] = true
removeSet[t] = true
}
var geaendert []Entry
jetzt := NowString()
var changed []Entry
now := NowString()
err := s.tx(ctx, func(tx *transaktion) error {
err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs {
var vorhandenID string
var existingID string
err := tx.QueryRowContext(ctx,
`SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb, id, projectID,
).Scan(&vorhandenID)
`SELECT id FROM entries WHERE id = ? AND project_id = ? AND `+notInTrash, id, projectID,
).Scan(&existingID)
if errors.Is(err, sql.ErrNoRows) {
continue // gehört nicht zu diesem Projekt, existiert nicht mehr oder liegt im Papierkorb
}
@ -681,14 +681,14 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
var bisherige []string
var previous []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
rows.Close()
return err
}
bisherige = append(bisherige, tag)
previous = append(previous, tag)
}
if err := rows.Err(); err != nil {
rows.Close()
@ -696,18 +696,18 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
}
rows.Close()
neu := NormalizeTags(append(bisherige, add...))
gefiltert := neu[:0]
for _, t := range neu {
if !entferneMenge[t] {
gefiltert = append(gefiltert, t)
newer := NormalizeTags(append(previous, add...))
filtered := newer[:0]
for _, t := range newer {
if !removeSet[t] {
filtered = append(filtered, t)
}
}
// Nur schreiben, wenn sich wirklich etwas ändert — sonst würde ein
// Sammelaufruf über eine gemischte Auswahl auch die Einträge
// "berühren", bei denen add/remove gar nichts bewirkt haben.
if stringSlicesEqual(bisherige, gefiltert) {
if stringSlicesEqual(previous, filtered) {
continue
}
@ -718,11 +718,11 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
return err
}
if err := replaceTags(ctx, tx, projectID, id, gefiltert); err != nil {
if err := replaceTags(ctx, tx, projectID, id, filtered); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, jetzt, id,
`UPDATE entries SET version = version + 1, updated_at = ? WHERE id = ?`, now, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht aktualisiert werden: %w", err)
}
@ -732,15 +732,15 @@ func (s *Store) BulkUpdateTags(ctx context.Context, projectID string, entryIDs,
if err != nil {
return fmt.Errorf("Eintrag konnte nicht neu gelesen werden: %w", err)
}
e.Tags = gefiltert
geaendert = append(geaendert, e)
e.Tags = filtered
changed = append(changed, e)
}
return nil
})
if err != nil {
return nil, err
}
return geaendert, nil
return changed, nil
}
// stringSlicesEqual vergleicht zwei (bereits sortierte) Zeichenketten-Listen.
@ -778,7 +778,7 @@ func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backli
//
// Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine
// Handvoll — dort kostet das Sortieren nichts.
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT e.slug, e.title
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
@ -828,20 +828,20 @@ type GraphEdge struct {
// Graph-Ansicht sichtbar, wohin schon verwiesen wird, was aber noch fehlt,
// statt solche Verweise stillschweigend zu unterschlagen.
func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, []GraphEdge, error) {
entryRows, err := s.lesen.QueryContext(ctx,
`SELECT slug, title FROM entries WHERE project_id = ? AND `+nichtImPapierkorb, projectID,
entryRows, err := s.read.QueryContext(ctx,
`SELECT slug, title FROM entries WHERE project_id = ? AND `+notInTrash, projectID,
)
if err != nil {
return nil, nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
knoten := make(map[string]*GraphNode)
nodes := make(map[string]*GraphNode)
for entryRows.Next() {
var slug, title string
if err := entryRows.Scan(&slug, &title); err != nil {
entryRows.Close()
return nil, nil, err
}
knoten[slug] = &GraphNode{Slug: slug, Title: title, Exists: true}
nodes[slug] = &GraphNode{Slug: slug, Title: title, Exists: true}
}
if err := entryRows.Err(); err != nil {
entryRows.Close()
@ -849,7 +849,7 @@ func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, [
}
entryRows.Close()
linkRows, err := s.lesen.QueryContext(ctx,
linkRows, err := s.read.QueryContext(ctx,
`SELECT e.slug, l.to_slug
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
@ -861,28 +861,28 @@ func (s *Store) LinkGraph(ctx context.Context, projectID string) ([]GraphNode, [
}
defer linkRows.Close()
var kanten []GraphEdge
var edges []GraphEdge
for linkRows.Next() {
var von, nach string
if err := linkRows.Scan(&von, &nach); err != nil {
var from, to string
if err := linkRows.Scan(&from, &to); err != nil {
return nil, nil, err
}
if _, ok := knoten[nach]; !ok {
knoten[nach] = &GraphNode{Slug: nach, Title: nach, Exists: false}
if _, ok := nodes[to]; !ok {
nodes[to] = &GraphNode{Slug: to, Title: to, Exists: false}
}
kanten = append(kanten, GraphEdge{From: von, To: nach})
edges = append(edges, GraphEdge{From: from, To: to})
}
if err := linkRows.Err(); err != nil {
return nil, nil, err
}
knotenListe := make([]GraphNode, 0, len(knoten))
for _, k := range knoten {
knotenListe = append(knotenListe, *k)
nodeList := make([]GraphNode, 0, len(nodes))
for _, k := range nodes {
nodeList = append(nodeList, *k)
}
sort.Slice(knotenListe, func(i, j int) bool { return knotenListe[i].Slug < knotenListe[j].Slug })
sort.Slice(nodeList, func(i, j int) bool { return nodeList[i].Slug < nodeList[j].Slug })
return knotenListe, kanten, nil
return nodeList, edges, nil
}
// boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.

View file

@ -45,13 +45,13 @@ func (s *Store) CreateAPIKey(ctx context.Context, projectID, name, hash, role st
return s.CreateAPIKeyWithExpiry(ctx, projectID, name, hash, role, time.Time{})
}
// CreateAPIKeyWithExpiry legt einen Key an, der ab ablauf nicht mehr gilt
// CreateAPIKeyWithExpiry legt einen Key an, der ab expiry nicht mehr gilt
// (AUSBAUSTUFEN.md 4.7, Stufe 2). Der Nullzeitpunkt heißt: läuft nie ab.
func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, ablauf time.Time) (APIKey, error) {
func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, hash, role string, expiry time.Time) (APIKey, error) {
if strings.TrimSpace(name) == "" {
return APIKey{}, fmt.Errorf("der Key braucht einen Namen — daran erkennt man später, wer oder was ihn benutzt")
}
if !ablauf.IsZero() && !ablauf.After(time.Now()) {
if !expiry.IsZero() && !expiry.After(time.Now()) {
return APIKey{}, fmt.Errorf("das Ablaufdatum liegt in der Vergangenheit — so wäre der Key nie gültig")
}
@ -61,24 +61,24 @@ func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, has
Name: strings.TrimSpace(name),
Hash: hash,
Role: role,
ExpiresAt: ablauf,
ExpiresAt: expiry,
}
jetzt := NowString()
ablaufText := ""
if !ablauf.IsZero() {
ablaufText = formatTime(ablauf)
now := NowString()
expiryText := ""
if !expiry.IsZero() {
expiryText = formatTime(expiry)
}
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO api_keys (id, project_id, name, hash, role, created_at, last_used_at, revoked_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, '', '', ?)`,
k.ID, k.ProjectID, k.Name, k.Hash, k.Role, jetzt, ablaufText,
k.ID, k.ProjectID, k.Name, k.Hash, k.Role, now, expiryText,
)
if err != nil {
return APIKey{}, fmt.Errorf("Key konnte nicht angelegt werden: %w", err)
}
k.CreatedAt = parseTime(jetzt)
k.CreatedAt = parseTime(now)
return k, nil
}
@ -91,7 +91,7 @@ func (s *Store) CreateAPIKeyWithExpiry(ctx context.Context, projectID, name, has
// Zurückgezogene und abgelaufene Keys gelten als nicht vorhanden: der
// Aufrufer erfährt nicht, ob es den Key nie gab oder ob er gesperrt wurde.
func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, error) {
row := s.lesen.QueryRowContext(ctx,
row := s.read.QueryRowContext(ctx,
`SELECT k.id, k.project_id, k.name, k.hash, k.role, k.created_at, k.last_used_at, k.revoked_at, k.expires_at,
p.id, p.workspace_id, p.slug, p.name, p.description, p.created_at
FROM api_keys k
@ -127,7 +127,7 @@ func (s *Store) ResolveKeyHash(ctx context.Context, hash string) (ResolvedKey, e
// APIKeyByID liest einen Key anhand seiner ID — auch einen zurückgezogenen,
// damit die Verwaltungsoberfläche ihn noch anzeigen kann.
func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) {
row := s.lesen.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id)
row := s.read.QueryRowContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE id = ?`, id)
k, err := scanKey(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
@ -142,7 +142,7 @@ func (s *Store) APIKeyByID(ctx context.Context, id string) (APIKey, error) {
// ListAPIKeys gibt alle Keys eines Projekts zurück, neueste zuerst.
// Zurückgezogene sind dabei — sie bleiben als Beleg im Audit-Log referenziert.
func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT `+keyColumns+` FROM api_keys WHERE project_id = ? ORDER BY created_at DESC`,
projectID,
)
@ -167,7 +167,7 @@ func (s *Store) ListAPIKeys(ctx context.Context, projectID string) ([]APIKey, er
// Der Datensatz bleibt bestehen und wird nur markiert — sonst verlöre das
// Audit-Log die Zuordnung zu allem, was dieser Key jemals geschrieben hat.
func (s *Store) RevokeAPIKey(ctx context.Context, id string) error {
res, err := s.schreiben.ExecContext(ctx,
res, err := s.write.ExecContext(ctx,
`UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at = ''`,
NowString(), id,
)
@ -190,6 +190,6 @@ func (s *Store) TouchAPIKey(ctx context.Context, id string, lastUsed time.Time)
if time.Since(lastUsed) < time.Hour {
return nil
}
_, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id)
_, err := s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err
}

View file

@ -22,53 +22,53 @@ var linkPattern = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9_-]{0,63})\]\]`)
// Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe
// Ziel ist keine zusätzliche Information.
//
// Was in Code steht, zählt nicht (siehe verweiseAusserhalbVonCode).
// Was in Code steht, zählt nicht (siehe refsOutsideCode).
func ExtractLinks(content string) []string {
treffer := verweiseAusserhalbVonCode(content)
if len(treffer) == 0 {
hits := refsOutsideCode(content)
if len(hits) == 0 {
return nil
}
gesehen := make(map[string]bool, len(treffer))
var ziele []string
seen := make(map[string]bool, len(hits))
var targets []string
for _, t := range treffer {
ziel := content[t[2]:t[3]]
if gesehen[ziel] {
for _, t := range hits {
target := content[t[2]:t[3]]
if seen[target] {
continue
}
gesehen[ziel] = true
ziele = append(ziele, ziel)
seen[target] = true
targets = append(targets, target)
}
sort.Strings(ziele)
return ziele
sort.Strings(targets)
return targets
}
// ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn.
//
// Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die
// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. Was in Code
// steht, bleibt unverändert (siehe verweiseAusserhalbVonCode).
// steht, bleibt unverändert (siehe refsOutsideCode).
func ReplaceLinks(content string, fn func(slug string) string) string {
treffer := verweiseAusserhalbVonCode(content)
if len(treffer) == 0 {
hits := refsOutsideCode(content)
if len(hits) == 0 {
return content
}
var b strings.Builder
b.Grow(len(content))
bisher := 0
for _, t := range treffer {
b.WriteString(content[bisher:t[0]])
sofar := 0
for _, t := range hits {
b.WriteString(content[sofar:t[0]])
b.WriteString(fn(content[t[2]:t[3]]))
bisher = t[1]
sofar = t[1]
}
b.WriteString(content[bisher:])
b.WriteString(content[sofar:])
return b.String()
}
// verweiseAusserhalbVonCode findet alle [[slug]]-Verweise, die NICHT in Code
// refsOutsideCode findet alle [[slug]]-Verweise, die NICHT in Code
// stehen. Jeder Treffer ist wie bei regexp.FindAllStringSubmatchIndex
// aufgebaut: [Anfang, Ende, Anfang des Slugs, Ende des Slugs].
//
@ -81,28 +81,28 @@ func ReplaceLinks(content string, fn func(slug string) string) string {
//
// Als Code gilt, was Markdown als Code darstellt: Codeblöcke zwischen ```
// oder ~~~ und Code im Fließtext zwischen Backticks.
func verweiseAusserhalbVonCode(content string) [][]int {
alle := linkPattern.FindAllStringSubmatchIndex(content, -1)
if len(alle) == 0 {
func refsOutsideCode(content string) [][]int {
all := linkPattern.FindAllStringSubmatchIndex(content, -1)
if len(all) == 0 {
return nil
}
code := codeBereiche(content)
code := codeRanges(content)
if len(code) == 0 {
return alle
return all
}
var draussen [][]int
for _, t := range alle {
if !inBereich(t[0], code) {
draussen = append(draussen, t)
var outside [][]int
for _, t := range all {
if !inRange(t[0], code) {
outside = append(outside, t)
}
}
return draussen
return outside
}
// inBereich sagt, ob die Stelle pos in einem der Bereiche liegt.
func inBereich(pos int, bereiche [][2]int) bool {
for _, b := range bereiche {
// inRange sagt, ob die Stelle pos in einem der Bereiche liegt.
func inRange(pos int, ranges [][2]int) bool {
for _, b := range ranges {
if pos >= b[0] && pos < b[1] {
return true
}
@ -110,7 +110,7 @@ func inBereich(pos int, bereiche [][2]int) bool {
return false
}
// codeBereiche liefert die Stellen eines Markdown-Texts, die Code sind, als
// codeRanges liefert die Stellen eines Markdown-Texts, die Code sind, als
// [Anfang, Ende) in Bytes.
//
// Das ist bewusst kein vollständiger Markdown-Parser, sondern deckt die zwei
@ -126,85 +126,85 @@ func inBereich(pos int, bereiche [][2]int) bool {
// Eingerückte Codeblöcke (vier Leerzeichen) fehlen absichtlich: Sie sind von
// eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein
// übersehener Verweis wäre schlimmer als ein überflüssiger.
func codeBereiche(content string) [][2]int {
var bereiche [][2]int
func codeRanges(content string) [][2]int {
var ranges [][2]int
// ── 1. Codeblöcke, Zeile für Zeile ───────────────────────────────────
var fliesstext [][2]int // was außerhalb von Codeblöcken liegt
blockAnfang := -1
var zaun string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks
zeilenAnfang := 0
textAnfang := 0
for zeilenAnfang < len(content) {
zeilenEnde := strings.IndexByte(content[zeilenAnfang:], '\n')
if zeilenEnde < 0 {
zeilenEnde = len(content)
var prose [][2]int // was außerhalb von Codeblöcken liegt
blockStart := -1
var fence string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks
lineStart := 0
textStart := 0
for lineStart < len(content) {
lineEnd := strings.IndexByte(content[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(content)
} else {
zeilenEnde += zeilenAnfang + 1 // samt Zeilenumbruch
lineEnd += lineStart + 1 // samt Zeilenumbruch
}
zeile := content[zeilenAnfang:zeilenEnde]
line := content[lineStart:lineEnd]
if blockAnfang < 0 {
if z := codeZaun(zeile); z != "" {
fliesstext = append(fliesstext, [2]int{textAnfang, zeilenAnfang})
blockAnfang, zaun = zeilenAnfang, z
if blockStart < 0 {
if z := codeFence(line); z != "" {
prose = append(prose, [2]int{textStart, lineStart})
blockStart, fence = lineStart, z
}
} else if z := codeZaun(zeile); z != "" && z[0] == zaun[0] && len(z) >= len(zaun) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(zeile), z[:1])) == "" {
} else if z := codeFence(line); z != "" && z[0] == fence[0] && len(z) >= len(fence) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), z[:1])) == "" {
// Schließender Zaun: nur Zaunzeichen, nichts dahinter.
bereiche = append(bereiche, [2]int{blockAnfang, zeilenEnde})
blockAnfang = -1
textAnfang = zeilenEnde
ranges = append(ranges, [2]int{blockStart, lineEnd})
blockStart = -1
textStart = lineEnd
}
zeilenAnfang = zeilenEnde
lineStart = lineEnd
}
if blockAnfang >= 0 {
bereiche = append(bereiche, [2]int{blockAnfang, len(content)})
if blockStart >= 0 {
ranges = append(ranges, [2]int{blockStart, len(content)})
} else {
fliesstext = append(fliesstext, [2]int{textAnfang, len(content)})
prose = append(prose, [2]int{textStart, len(content)})
}
// ── 2. Code im Fließtext, nur außerhalb der Codeblöcke ───────────────
for _, f := range fliesstext {
for _, f := range prose {
text := content[f[0]:f[1]]
for i := 0; i < len(text); {
if text[i] != '`' {
i++
continue
}
laenge := laufLaenge(text, i, '`')
ende := schliessendeBackticks(text, i+laenge, laenge)
if ende < 0 {
i += laenge // kein Gegenstück: kein Code
length := runLength(text, i, '`')
end := closingBackticks(text, i+length, length)
if end < 0 {
i += length // kein Gegenstück: kein Code
continue
}
bereiche = append(bereiche, [2]int{f[0] + i, f[0] + ende + laenge})
i = ende + laenge
ranges = append(ranges, [2]int{f[0] + i, f[0] + end + length})
i = end + length
}
}
return bereiche
return ranges
}
// codeZaun liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock
// codeFence liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock
// öffnet oder schließt, sonst "".
func codeZaun(zeile string) string {
eingerueckt := len(zeile) - len(strings.TrimLeft(zeile, " "))
if eingerueckt > 3 {
func codeFence(line string) string {
indented := len(line) - len(strings.TrimLeft(line, " "))
if indented > 3 {
return ""
}
rest := zeile[eingerueckt:]
rest := line[indented:]
if rest == "" || (rest[0] != '`' && rest[0] != '~') {
return ""
}
n := laufLaenge(rest, 0, rest[0])
n := runLength(rest, 0, rest[0])
if n < 3 {
return ""
}
return rest[:n]
}
// laufLaenge zählt, wie oft das Zeichen c ab Stelle i hintereinander steht.
func laufLaenge(text string, i int, c byte) int {
// runLength zählt, wie oft das Zeichen c ab Stelle i hintereinander steht.
func runLength(text string, i int, c byte) int {
n := 0
for i+n < len(text) && text[i+n] == c {
n++
@ -212,16 +212,16 @@ func laufLaenge(text string, i int, c byte) int {
return n
}
// schliessendeBackticks sucht ab Stelle ab eine Folge von genau laenge
// closingBackticks sucht ab Stelle from eine Folge von genau length
// Backticks und gibt ihre Stelle zurück, sonst -1.
func schliessendeBackticks(text string, ab, laenge int) int {
for i := ab; i < len(text); {
func closingBackticks(text string, from, length int) int {
for i := from; i < len(text); {
if text[i] != '`' {
i++
continue
}
n := laufLaenge(text, i, '`')
if n == laenge {
n := runLength(text, i, '`')
if n == length {
return i
}
i += n

View file

@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to;
-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index
-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort
-- in einem Projekt" ab.
CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug);
CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug);

View file

@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT '';
--
-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg
-- dorthin bleibt offen.
CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_entries_trash ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL;

View file

@ -43,5 +43,5 @@ DROP INDEX idx_entry_links_to;
-- project_id zuerst, dann das eigentliche Suchmerkmal: So deckt der Index
-- sowohl "alle Schlagworte eines Projekts" als auch "ein bestimmtes Schlagwort
-- in einem Projekt" ab.
CREATE INDEX idx_entry_tags_projekt ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_projekt ON entry_links (project_id, to_slug);
CREATE INDEX idx_entry_tags_project ON entry_tags (project_id, tag);
CREATE INDEX idx_entry_links_project ON entry_links (project_id, to_slug);

View file

@ -38,4 +38,4 @@ ALTER TABLE entries ADD COLUMN deleted_by TEXT NOT NULL DEFAULT '';
--
-- Teilindizes (CREATE INDEX … WHERE …) kennt PostgreSQL genauso — der Weg
-- dorthin bleibt offen.
CREATE INDEX idx_entries_papierkorb ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_entries_trash ON entries (project_id, deleted_at) WHERE deleted_at IS NOT NULL;

View file

@ -69,13 +69,13 @@ func (k APIKey) Revoked() bool {
// Expired sagt, ob der Key abgelaufen ist. Ein abgelaufener Key gilt wie ein
// zurückgezogener (siehe ResolveKeyHash und sessionFromSubject).
func (k APIKey) Expired(jetzt time.Time) bool {
return !k.ExpiresAt.IsZero() && !jetzt.Before(k.ExpiresAt)
func (k APIKey) Expired(now time.Time) bool {
return !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt)
}
// Valid sagt, ob der Key gerade gilt: weder zurückgezogen noch abgelaufen.
func (k APIKey) Valid(jetzt time.Time) bool {
return !k.Revoked() && !k.Expired(jetzt)
func (k APIKey) Valid(now time.Time) bool {
return !k.Revoked() && !k.Expired(now)
}
// ExpiresSoonWithin ist die Zeitspanne vor dem Ablauf, in der die Oberfläche
@ -85,8 +85,8 @@ const ExpiresSoonWithin = 14 * 24 * time.Hour
// ExpiresSoon sagt, ob ein gültiger Key innerhalb von ExpiresSoonWithin
// abläuft.
func (k APIKey) ExpiresSoon(jetzt time.Time) bool {
return k.Valid(jetzt) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(jetzt) <= ExpiresSoonWithin
func (k APIKey) ExpiresSoon(now time.Time) bool {
return k.Valid(now) && !k.ExpiresAt.IsZero() && k.ExpiresAt.Sub(now) <= ExpiresSoonWithin
}
// UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als
@ -103,26 +103,26 @@ const UnusedAfter = 90 * 24 * time.Hour
// Zurückgezogene Keys sind nie „ungenutzt“, denn sie sind ohnehin kein offener
// Zugang mehr.
//
// jetzt wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
// now wird hereingereicht statt time.Now() zu rufen, damit Tests einen festen
// Zeitpunkt vorgeben können.
//
// Abgelaufene Keys sind ebenso wenig „ungenutzt" — auch sie öffnen nichts mehr.
func (k APIKey) Unused(jetzt time.Time) bool {
return ungenutzt(!k.Valid(jetzt), k.LastUsedAt, k.CreatedAt, jetzt)
func (k APIKey) Unused(now time.Time) bool {
return unused(!k.Valid(now), k.LastUsedAt, k.CreatedAt, now)
}
// ungenutzt ist die gemeinsame Regel hinter APIKey.Unused und
// unused ist die gemeinsame Regel hinter APIKey.Unused und
// WorkspaceAdmin.Unused — beide Zugangsarten sollen nach demselben Maßstab
// als „ungenutzt“ gelten.
func ungenutzt(zurueckgezogen bool, zuletztBenutzt, angelegt, jetzt time.Time) bool {
if zurueckgezogen {
func unused(revoked bool, lastUsed, created, now time.Time) bool {
if revoked {
return false
}
zuletzt := zuletztBenutzt
if zuletzt.IsZero() {
zuletzt = angelegt
latest := lastUsed
if latest.IsZero() {
latest = created
}
return jetzt.Sub(zuletzt) > UnusedAfter
return now.Sub(latest) > UnusedAfter
}
// Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben.
@ -166,21 +166,21 @@ const StaleAfter = 365 * 24 * time.Hour
//
// Auch geprüfte Einträge (Curated) können veralten. Das Häkchen sagt nur, dass
// ein Mensch den Eintrag irgendwann bestätigt hat, nicht wann.
func (e Entry) Stale(jetzt time.Time) bool {
return StaleSince(e.UpdatedAt, jetzt)
func (e Entry) Stale(now time.Time) bool {
return StaleSince(e.UpdatedAt, now)
}
// StaleSince ist dieselbe Regel für Stellen, die nur den Änderungszeitpunkt
// kennen, aber keinen ganzen Entry, etwa verwandte Einträge (RelatedEntry).
func StaleSince(geaendert, jetzt time.Time) bool {
return !geaendert.IsZero() && jetzt.Sub(geaendert) > StaleAfter
func StaleSince(changed, now time.Time) bool {
return !changed.IsZero() && now.Sub(changed) > StaleAfter
}
// StaleCutoff ist der Zeitpunkt, vor dem ein Eintrag zuletzt geändert worden
// sein muss, um als veraltet zu gelten — dieselbe Grenze wie StaleSince, nur
// als Wert für eine Abfrage.
func StaleCutoff(jetzt time.Time) time.Time {
return jetzt.Add(-StaleAfter)
func StaleCutoff(now time.Time) time.Time {
return now.Add(-StaleAfter)
}
// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite
@ -247,16 +247,16 @@ type AuditEvent struct {
// zugleich chronologisch sind — praktisch fürs Audit-Log und für Indizes, weil
// neue Werte immer hinten angehängt werden statt den Baum zu zerstreuen.
func NewID() string {
zeit := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36)
ts := strconv.FormatInt(time.Now().UTC().UnixMilli(), 36)
zufall := make([]byte, 4)
if _, err := rand.Read(zufall); err != nil {
rng := make([]byte, 4)
if _, err := rand.Read(rng); err != nil {
// crypto/rand schlägt praktisch nie fehl. Falls doch, reicht der
// Zeitanteil zur Not aus — er ist auf die Millisekunde genau.
return zeit
return ts
}
return zeit + "-" + hex.EncodeToString(zufall)
return ts + "-" + hex.EncodeToString(rng)
}
// timeFormat ist das Format aller Zeitstempel in der Datenbank.

90
internal/store/pool.go Normal file
View file

@ -0,0 +1,90 @@
package store
import (
"context"
"database/sql"
)
// pool ist ein Verbindungspool samt dem Dialekt seiner Datenbank.
//
// # Warum nicht direkt *sql.DB?
//
// Jede Abfrage muss durch dialect.rebind, bevor sie an die Datenbank geht —
// sonst bekommt PostgreSQL ein "?", mit dem es nichts anfangen kann. Würde der
// Store *sql.DB direkt benutzen, wäre das bei jeder der rund 100 Abfragen eine
// Stelle, an der man es vergessen kann.
//
// pool bietet deshalb nur die Methoden an, die der Store braucht, und jede
// davon wandelt um. An *sql.DB selbst kommt außerhalb dieser Datei niemand
// heran — eine Abfrage am Dialekt vorbei lässt sich gar nicht erst schreiben.
type pool struct {
db *sql.DB
d dialect
}
func (p pool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return p.db.ExecContext(ctx, p.d.rebind(query), args...)
}
func (p pool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return p.db.QueryContext(ctx, p.d.rebind(query), args...)
}
func (p pool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return p.db.QueryRowContext(ctx, p.d.rebind(query), args...)
}
// beginTx startet eine Transaktion, deren Abfragen ebenfalls umgewandelt
// werden.
func (p pool) beginTx(ctx context.Context) (*transaction, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &transaction{tx: tx, d: p.d}, nil
}
func (p pool) Close() error { return p.db.Close() }
// PoolStats sind die Zahlen eines Lese- und eines Schreibpools, wie
// database/sql sie führt — für die Systemseite (AUSBAUSTUFEN.md 5.12).
type PoolStats struct {
Read, Write sql.DBStats
}
// OpenConns zählt die offenen Verbindungen beider Pools zusammen. Genau so viele
// belegt Munin gerade beim Datenbankserver.
func (v PoolStats) OpenConns() int { return v.Read.OpenConnections + v.Write.OpenConnections }
// InUse zählt die Verbindungen, die gerade eine Abfrage ausführen.
func (v PoolStats) InUse() int { return v.Read.InUse + v.Write.InUse }
// WaitCount zählt, wie oft eine Anfrage auf eine freie Verbindung warten
// musste, seit der Pool geöffnet wurde. Wächst die Zahl stetig, sind die
// Pools zu klein für die Last.
func (v PoolStats) WaitCount() int64 { return v.Read.WaitCount + v.Write.WaitCount }
// transaction ist das Gegenstück zu pool für *sql.Tx: dieselben Methoden,
// dieselbe Umwandlung.
type transaction struct {
tx *sql.Tx
d dialect
}
func (t *transaction) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return t.tx.ExecContext(ctx, t.d.rebind(query), args...)
}
func (t *transaction) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return t.tx.QueryContext(ctx, t.d.rebind(query), args...)
}
func (t *transaction) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return t.tx.QueryRowContext(ctx, t.d.rebind(query), args...)
}
func (t *transaction) Commit() error { return t.tx.Commit() }
// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann es
// bedenkenlos per defer stehen.
func (t *transaction) Rollback() error { return t.tx.Rollback() }

View file

@ -32,13 +32,13 @@ import (
const pgTestVariable = "MUNIN_TEST_PG"
// pgTestAktiv sagt, ob die Tests gegen PostgreSQL laufen sollen.
func pgTestAktiv() bool { return os.Getenv(pgTestVariable) != "" }
// pgTestEnabled sagt, ob die Tests gegen PostgreSQL laufen sollen.
func pgTestEnabled() bool { return os.Getenv(pgTestVariable) != "" }
var (
pgTestOnce sync.Once
pgTestSpeicher *PostgresSpeicher
pgTestFehler error
pgTestOnce sync.Once
pgTestBackend *PostgresBackend
pgTestErr error
)
// testPostgres liefert den gemeinsamen Speicher aller PostgreSQL-Tests dieses
@ -46,18 +46,18 @@ var (
//
// Ein gemeinsamer Speicher statt einem je Test: Er hält eine
// Verwaltungsverbindung offen, und die braucht es nur einmal.
func testPostgres(t *testing.T) *PostgresSpeicher {
func testPostgres(t *testing.T) *PostgresBackend {
t.Helper()
if !pgTestAktiv() {
if !pgTestEnabled() {
t.Skipf("%s ist nicht gesetzt — PostgreSQL-Test übersprungen", pgTestVariable)
}
pgTestOnce.Do(func() {
pgTestSpeicher, pgTestFehler = NewPostgres(context.Background(), os.Getenv(pgTestVariable))
pgTestBackend, pgTestErr = NewPostgres(context.Background(), os.Getenv(pgTestVariable))
})
if pgTestFehler != nil {
t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestFehler)
if pgTestErr != nil {
t.Fatalf("PostgreSQL für die Tests nicht erreichbar: %v", pgTestErr)
}
return pgTestSpeicher
return pgTestBackend
}
// testPostgresStore öffnet einen Workspace in einem frischen Schema und löscht
@ -90,14 +90,14 @@ func testPostgresControl(t *testing.T) *ControlStore {
// Eigener Speicher mit eigenem Kontroll-Schema, aber derselben
// Verwaltungsverbindung.
schema := "munin_test_control_" + strings.ReplaceAll(NewID(), "-", "_")
eigener := *sp
eigener.controlSchema = schema
own := *sp
own.controlSchema = schema
ctx := context.Background()
if err := eigener.legeSchemaAn(ctx, schema); err != nil {
if err := own.createSchema(ctx, schema); err != nil {
t.Fatal(err)
}
c, err := eigener.OpenControl()
c, err := own.OpenControl()
if err != nil {
t.Fatalf("Kontroll-Schema konnte nicht angelegt werden: %v", err)
}
@ -105,11 +105,11 @@ func testPostgresControl(t *testing.T) *ControlStore {
// Jeden angelegten Workspace mitschreiben. Die Liste aus der
// Kontroll-Datenbank reicht zum Aufräumen nicht: Ein Test, der dort einen
// Workspace löscht, ließe sonst dessen Schema liegen.
merker := &angelegteWorkspaces{PostgresSpeicher: &eigener}
c.haken = merker
tracker := &createdWorkspaces{PostgresBackend: &own}
c.hooks = tracker
t.Cleanup(func() {
for _, id := range merker.ids {
for _, id := range tracker.ids {
if err := sp.DeleteWorkspace(id); err != nil {
t.Errorf("Aufräumen: %v", err)
}
@ -122,32 +122,32 @@ func testPostgresControl(t *testing.T) *ControlStore {
return c
}
// angelegteWorkspaces reicht die Haken an den Speicher weiter und merkt sich
// createdWorkspaces reicht die Haken an den Speicher weiter und merkt sich
// dabei jeden Workspace, für den ein Schema angelegt wurde.
type angelegteWorkspaces struct {
*PostgresSpeicher
type createdWorkspaces struct {
*PostgresBackend
mu sync.Mutex
ids []string
}
func (a *angelegteWorkspaces) nachDemBenennen(ctx context.Context, workspaceID, name string) error {
func (a *createdWorkspaces) afterRename(ctx context.Context, workspaceID, name string) error {
a.mu.Lock()
if !slices.Contains(a.ids, workspaceID) {
a.ids = append(a.ids, workspaceID)
}
a.mu.Unlock()
return a.PostgresSpeicher.nachDemBenennen(ctx, workspaceID, name)
return a.PostgresBackend.afterRename(ctx, workspaceID, name)
}
// ── Tests, die nur mit PostgreSQL laufen ─────────────────────────────────────
// TestSchemaGleich prüft, dass die Migrationen beider Ordner dasselbe Schema
// TestSchemaMatches prüft, dass die Migrationen beider Ordner dasselbe Schema
// ergeben: dieselben Tabellen, Spalten, NOT NULL, Standardwerte und Indizes.
//
// TestMigrationsordnerGleich stellt nur sicher, dass beide Ordner dieselben
// TestMigrationDirsMatch stellt nur sicher, dass beide Ordner dieselben
// Dateien haben. Erst dieser Test merkt, wenn eine Spalte auf einer Seite
// anders angelegt wurde — siehe AUSBAUSTUFEN.md 4.10.
func TestSchemaGleich(t *testing.T) {
func TestSchemaMatches(t *testing.T) {
ctx := context.Background()
t.Run("Workspace", func(t *testing.T) {
@ -158,7 +158,7 @@ func TestSchemaGleich(t *testing.T) {
defer sqlite.Close()
pg := testPostgresStore(t)
vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen))
compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read))
})
t.Run("Kontrolle", func(t *testing.T) {
@ -169,19 +169,19 @@ func TestSchemaGleich(t *testing.T) {
defer sqlite.Close()
pg := testPostgresControl(t)
vergleicheSchema(t, schemaSQLite(ctx, t, sqlite.lesen), schemaPostgres(ctx, t, pg.lesen))
compareSchema(t, schemaSQLite(ctx, t, sqlite.read), schemaPostgres(ctx, t, pg.read))
})
}
// TestPostgresSchemaJeWorkspace prüft den Weg eines Workspace durch
// TestPostgresSchemaPerWorkspace prüft den Weg eines Workspace durch
// PostgreSQL: Anlegen erzeugt ein Schema mit dem Namen als Kommentar,
// Umbenennen zieht den Kommentar nach, Löschen entfernt das Schema.
func TestPostgresSchemaJeWorkspace(t *testing.T) {
func TestPostgresSchemaPerWorkspace(t *testing.T) {
sp := testPostgres(t)
c := testPostgresControl(t)
ctx := context.Background()
kommentar := func(schema string) (string, bool) {
comment := func(schema string) (string, bool) {
var text sql.NullString
err := sp.admin.QueryRowContext(ctx,
`SELECT obj_description(oid, 'pg_namespace') FROM pg_namespace WHERE nspname = $1`, schema,
@ -200,18 +200,18 @@ func TestPostgresSchemaJeWorkspace(t *testing.T) {
t.Fatal(err)
}
schema, _ := WorkspaceSchema(w.ID)
if ort := sp.Ort(w.ID); ort != schema {
t.Errorf("Ort = %q, erwartet das Schema %q", ort, schema)
if location := sp.Location(w.ID); location != schema {
t.Errorf("Ort = %q, erwartet das Schema %q", location, schema)
}
if text, da := kommentar(schema); !da || text != "Vertrieb & Co's" {
t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", da, text)
if text, present := comment(schema); !present || text != "Vertrieb & Co's" {
t.Errorf("nach dem Anlegen: Schema da=%v, Kommentar=%q", present, text)
}
if err := c.UpdateWorkspace(ctx, w.ID, "Vertrieb Nord"); err != nil {
t.Fatal(err)
}
if text, _ := kommentar(schema); text != "Vertrieb Nord" {
if text, _ := comment(schema); text != "Vertrieb Nord" {
t.Errorf("nach dem Umbenennen: Kommentar=%q", text)
}
@ -228,14 +228,14 @@ func TestPostgresSchemaJeWorkspace(t *testing.T) {
if err := sp.DeleteWorkspace(w.ID); err != nil {
t.Fatal(err)
}
if _, da := kommentar(schema); da {
if _, present := comment(schema); present {
t.Error("Schema ist nach DeleteWorkspace noch da")
}
}
// TestPostgresWorkspacesGetrennt: Was in einem Workspace-Schema steht, ist im
// TestPostgresWorkspacesSeparated: Was in einem Workspace-Schema steht, ist im
// anderen nicht zu sehen — dieselbe Abschottung wie mit getrennten Dateien.
func TestPostgresWorkspacesGetrennt(t *testing.T) {
func TestPostgresWorkspacesSeparated(t *testing.T) {
a := testPostgresStore(t)
b := testPostgresStore(t)
ctx := context.Background()
@ -248,16 +248,16 @@ func TestPostgresWorkspacesGetrennt(t *testing.T) {
}
}
// TestPostgresKapazitaet: Zu viele Workspaces für max_connections werden mit
// TestPostgresCapacity: Zu viele Workspaces für max_connections werden mit
// einer Meldung abgelehnt, die die nötige Zahl nennt.
func TestPostgresKapazitaet(t *testing.T) {
func TestPostgresCapacity(t *testing.T) {
sp := testPostgres(t)
ctx := context.Background()
if err := sp.PruefeKapazitaet(ctx, 1); err != nil {
if err := sp.CheckCapacity(ctx, 1); err != nil {
t.Errorf("ein Workspace sollte immer passen: %v", err)
}
err := sp.PruefeKapazitaet(ctx, 100000)
err := sp.CheckCapacity(ctx, 100000)
if err == nil || !strings.Contains(err.Error(), "max_connections auf mindestens") {
t.Errorf("Fehler = %v, erwartet eine Meldung mit der nötigen Zahl", err)
}
@ -265,72 +265,72 @@ func TestPostgresKapazitaet(t *testing.T) {
// ── Schema auslesen und vergleichen ──────────────────────────────────────────
// schemaBild ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass
// schemaSnapshot ist das ausgelesene Schema einer Datenbank, so aufbereitet, dass
// SQLite und PostgreSQL vergleichbar werden. Jede Spalte wird zu einem Text
// wie "text NOT NULL DEFAULT 0".
type schemaBild struct {
spalten map[string]string // "tabelle.spalte" → Beschreibung
indizes []string // eigene Indizes (idx_…), sortiert
type schemaSnapshot struct {
columns map[string]string // "tabelle.spalte" → Beschreibung
indexes []string // eigene Indizes (idx_…), sortiert
}
// typKlasse fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite)
// typeClass fasst die Typnamen beider Datenbanken zusammen. INTEGER (SQLite)
// und integer/bigint (PostgreSQL) sind für Munin dasselbe: eine Ganzzahl.
func typKlasse(typ string) string {
switch strings.ToLower(typ) {
func typeClass(kind string) string {
switch strings.ToLower(kind) {
case "integer", "bigint", "int", "int8", "int4":
return "ganzzahl"
case "text":
return "text"
}
return "?" + typ
return "?" + kind
}
// standardwert gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte
// defaultValue gleicht die Schreibweise an: PostgreSQL hängt an Standardwerte
// den Typ an (leerer Text samt "::text"), SQLite nicht.
var textTypAngabe = regexp.MustCompile(`::[a-z ]+$`)
var textTypeSpec = regexp.MustCompile(`::[a-z ]+$`)
func standardwert(wert sql.NullString) string {
if !wert.Valid {
func defaultValue(value sql.NullString) string {
if !value.Valid {
return ""
}
return " DEFAULT " + textTypAngabe.ReplaceAllString(wert.String, "")
return " DEFAULT " + textTypeSpec.ReplaceAllString(value.String, "")
}
func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaBild {
func schemaSQLite(ctx context.Context, t *testing.T, p pool) schemaSnapshot {
t.Helper()
bild := schemaBild{spalten: map[string]string{}}
snapshot := schemaSnapshot{columns: map[string]string{}}
tabellen := abfrageListe(ctx, t, p,
tables := queryList(ctx, t, p,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
for _, tabelle := range tabellen {
rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, tabelle)
for _, table := range tables {
rows, err := p.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, table)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var name, typ string
var nichtNull, pk int
var name, kind string
var notNull, pk int
var standard sql.NullString
if err := rows.Scan(&name, &typ, &nichtNull, &standard, &pk); err != nil {
if err := rows.Scan(&name, &kind, &notNull, &standard, &pk); err != nil {
t.Fatal(err)
}
// Ein Primärschlüssel ist in PostgreSQL immer NOT NULL, in SQLite
// steht es nur dabei, wenn es ausdrücklich geschrieben wurde.
bild.spalten[tabelle+"."+name] = spaltenText(typ, nichtNull == 1 || pk > 0, standard)
snapshot.columns[table+"."+name] = columnsText(kind, notNull == 1 || pk > 0, standard)
}
rows.Close()
}
// Ausdrücklich angelegte Indizes haben einen SQL-Text, die automatischen
// für PRIMARY KEY und UNIQUE nicht.
bild.indizes = abfrageListe(ctx, t, p,
snapshot.indexes = queryList(ctx, t, p,
`SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name`)
return bild
return snapshot
}
func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild {
func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaSnapshot {
t.Helper()
bild := schemaBild{spalten: map[string]string{}}
snapshot := schemaSnapshot{columns: map[string]string{}}
rows, err := p.QueryContext(ctx,
`SELECT table_name, column_name, data_type, is_nullable, column_default
@ -340,68 +340,68 @@ func schemaPostgres(ctx context.Context, t *testing.T, p pool) schemaBild {
t.Fatal(err)
}
for rows.Next() {
var tabelle, name, typ, nullbar string
var table, name, kind, nullable string
var standard sql.NullString
if err := rows.Scan(&tabelle, &name, &typ, &nullbar, &standard); err != nil {
if err := rows.Scan(&table, &name, &kind, &nullable, &standard); err != nil {
t.Fatal(err)
}
bild.spalten[tabelle+"."+name] = spaltenText(typ, nullbar == "NO", standard)
snapshot.columns[table+"."+name] = columnsText(kind, nullable == "NO", standard)
}
rows.Close()
// Die Indizes hinter PRIMARY KEY und UNIQUE benennt PostgreSQL selbst
// (…_pkey, …_key). Verglichen werden nur die eigenen, die mit idx_ beginnen
// — so heißen sie in den Migrationen.
bild.indizes = abfrageListe(ctx, t, p,
snapshot.indexes = queryList(ctx, t, p,
`SELECT indexname FROM pg_indexes WHERE schemaname = current_schema() AND indexname LIKE 'idx\_%' ORDER BY indexname`)
return bild
return snapshot
}
func spaltenText(typ string, nichtNull bool, standard sql.NullString) string {
text := typKlasse(typ)
if nichtNull {
func columnsText(kind string, notNull bool, standard sql.NullString) string {
text := typeClass(kind)
if notNull {
text += " NOT NULL"
}
return text + standardwert(standard)
return text + defaultValue(standard)
}
func abfrageListe(ctx context.Context, t *testing.T, p pool, abfrage string) []string {
func queryList(ctx context.Context, t *testing.T, p pool, query string) []string {
t.Helper()
rows, err := p.QueryContext(ctx, abfrage)
rows, err := p.QueryContext(ctx, query)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var liste []string
var list []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatal(err)
}
liste = append(liste, s)
list = append(list, s)
}
return liste
return list
}
func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) {
func compareSchema(t *testing.T, sqlite, postgres schemaSnapshot) {
t.Helper()
alle := map[string]bool{}
for k := range sqlite.spalten {
alle[k] = true
all := map[string]bool{}
for k := range sqlite.columns {
all[k] = true
}
for k := range postgres.spalten {
alle[k] = true
for k := range postgres.columns {
all[k] = true
}
var namen []string
for k := range alle {
namen = append(namen, k)
var names []string
for k := range all {
names = append(names, k)
}
sort.Strings(namen)
sort.Strings(names)
for _, name := range namen {
s, inSQLite := sqlite.spalten[name]
p, inPostgres := postgres.spalten[name]
for _, name := range names {
s, inSQLite := sqlite.columns[name]
p, inPostgres := postgres.columns[name]
switch {
case !inSQLite:
t.Errorf("%s gibt es nur in PostgreSQL (%s)", name, p)
@ -412,7 +412,7 @@ func vergleicheSchema(t *testing.T, sqlite, postgres schemaBild) {
}
}
if fmt.Sprint(sqlite.indizes) != fmt.Sprint(postgres.indizes) {
t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indizes, postgres.indizes)
if fmt.Sprint(sqlite.indexes) != fmt.Sprint(postgres.indexes) {
t.Errorf("Indizes unterscheiden sich:\n sqlite: %v\n postgres: %v", sqlite.indexes, postgres.indexes)
}
}

View file

@ -26,30 +26,30 @@ func ValidSlug(slug string) bool {
// Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt.
func Slugify(text string) string {
var b strings.Builder
letzterStrich := false
lastDash := false
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
letzterStrich = false
lastDash = false
case r == 'ä':
b.WriteString("ae")
letzterStrich = false
lastDash = false
case r == 'ö':
b.WriteString("oe")
letzterStrich = false
lastDash = false
case r == 'ü':
b.WriteString("ue")
letzterStrich = false
lastDash = false
case r == 'ß':
b.WriteString("ss")
letzterStrich = false
lastDash = false
default:
// Alles andere wird zu einem Bindestrich, aber nie zwei am Stück.
if !letzterStrich && b.Len() > 0 {
if !lastDash && b.Len() > 0 {
b.WriteRune('-')
letzterStrich = true
lastDash = true
}
}
}
@ -83,12 +83,12 @@ func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, desc
Name: name,
Description: description,
}
jetzt := NowString()
now := NowString()
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO projects (id, workspace_id, slug, name, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, jetzt,
p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, now,
)
if isUniqueViolation(err) {
return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate)
@ -97,7 +97,7 @@ func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, desc
return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err)
}
p.CreatedAt = parseTime(jetzt)
p.CreatedAt = parseTime(now)
return p, nil
}
@ -122,7 +122,7 @@ func scanProject(scan func(...any) error) (Project, error) {
// ProjectByID liest ein Projekt anhand seiner ID.
func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) {
row := s.lesen.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id)
row := s.read.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
@ -136,7 +136,7 @@ func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) {
// ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace.
func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) {
row := s.lesen.QueryRowContext(ctx,
row := s.read.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`,
workspaceID, slug,
)
@ -153,7 +153,7 @@ func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Pr
// ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert.
func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`,
workspaceID,
)
@ -162,15 +162,15 @@ func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project
}
defer rows.Close()
var projekte []Project
var projects []Project
for rows.Next() {
p, err := scanProject(rows.Scan)
if err != nil {
return nil, err
}
projekte = append(projekte, p)
projects = append(projects, p)
}
return projekte, rows.Err()
return projects, rows.Err()
}
// ProjectUpdate sind die änderbaren Felder eines Projekts.
@ -207,7 +207,7 @@ func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) e
return fmt.Errorf("Obergrenze darf nicht negativ sein (%d)", u.WriteLimitHourly)
}
res, err := s.schreiben.ExecContext(ctx,
res, err := s.write.ExecContext(ctx,
`UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`,
u.Name, u.Description, u.WriteLimitHourly, id,
)
@ -222,7 +222,7 @@ func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) e
// Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist
// wichtig, dass foreign_keys eingeschaltet ist (siehe Open).
func (s *Store) DeleteProject(ctx context.Context, id string) error {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
res, err := s.write.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err)
}

View file

@ -62,27 +62,27 @@ func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxD
// Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im
// Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für
// unverknüpft statt für nicht vorhanden.
var vorhanden int
if err := s.lesen.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
var existing int
if err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug,
).Scan(&vorhanden); err != nil {
).Scan(&existing); err != nil {
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if vorhanden == 0 {
if existing == 0 {
return nil, ErrNotFound
}
// besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf
// visited verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf
// B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an
// als besucht, er soll nicht als sein eigener Nachbar erscheinen.
besucht := map[string]bool{slug: true}
ebene := []string{slug}
visited := map[string]bool{slug: true}
level := []string{slug}
var ergebnis []RelatedEntry
var result []RelatedEntry
for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ {
kanten, err := s.nachbarn(ctx, projectID, ebene)
for distance := 1; distance <= maxDistance && len(level) > 0 && len(result) < limit; distance++ {
edges, err := s.neighbors(ctx, projectID, level)
if err != nil {
return nil, err
}
@ -90,89 +90,89 @@ func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxD
// Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher
// Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in
// welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind
// die Kanten vorher sortiert (siehe nachbarn).
neu := make(map[string]RelatedEntry)
var neueSlugs []string
for _, k := range kanten {
if besucht[k.nach] {
// die Kanten vorher sortiert (siehe neighbors).
newer := make(map[string]RelatedEntry)
var newSlugs []string
for _, k := range edges {
if visited[k.to] {
continue
}
if _, schonDa := neu[k.nach]; schonDa {
if _, already := newer[k.to]; already {
continue
}
neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation}
neueSlugs = append(neueSlugs, k.nach)
newer[k.to] = RelatedEntry{Slug: k.to, Distance: distance, Via: k.from, Relation: k.relation}
newSlugs = append(newSlugs, k.to)
}
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
// der Antwort.
details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs)
details, err := s.entryHeads(ctx, projectID, newSlugs)
if err != nil {
return nil, err
}
var dieseEbene []RelatedEntry
ebene = ebene[:0]
for _, nachbar := range neueSlugs {
besucht[nachbar] = true
kopf, ok := details[nachbar]
var thisLevel []RelatedEntry
level = level[:0]
for _, neighbor := range newSlugs {
visited[neighbor] = true
head, ok := details[neighbor]
if !ok {
continue
}
r := neu[nachbar]
r.Title = kopf.Title
r.UpdatedAt = kopf.UpdatedAt
dieseEbene = append(dieseEbene, r)
ebene = append(ebene, nachbar)
r := newer[neighbor]
r.Title = head.Title
r.UpdatedAt = head.UpdatedAt
thisLevel = append(thisLevel, r)
level = append(level, neighbor)
}
sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title })
ergebnis = append(ergebnis, dieseEbene...)
sort.Slice(thisLevel, func(i, j int) bool { return thisLevel[i].Title < thisLevel[j].Title })
result = append(result, thisLevel...)
}
if len(ergebnis) > limit {
ergebnis = ergebnis[:limit]
if len(result) > limit {
result = result[:limit]
}
return ergebnis, nil
return result, nil
}
// kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
type kante struct {
von, nach string
relation string
// edge ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
type edge struct {
from, to string
relation string
}
// nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren,
// neighbors liefert alle Verweise, die einen der Bezeichner in slugs berühren,
// in beide Richtungen.
//
// Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die
// ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die
// eingehenden über idx_entry_links_projekt (project_id, to_slug).
func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) {
platz, args := inListe(projectID, slugs)
// eingehenden über idx_entry_links_project (project_id, to_slug).
func (s *Store) neighbors(ctx context.Context, projectID string, slugs []string) ([]edge, error) {
placeholders, args := inList(projectID, slugs)
var kanten []kante
var edges []edge
// Ausgehend: Einträge der Ebene verweisen auf etwas.
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT e.slug, l.to_slug
FROM entries e
JOIN entry_links l ON l.from_entry_id = e.id
WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`,
WHERE e.project_id = ? AND e.slug IN (`+placeholders+`) AND e.deleted_at IS NULL`,
args...,
)
if err != nil {
return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err)
}
for rows.Next() {
var k kante
if err := rows.Scan(&k.von, &k.nach); err != nil {
var k edge
if err := rows.Scan(&k.from, &k.to); err != nil {
rows.Close()
return nil, err
}
k.relation = RelationLinksTo
kanten = append(kanten, k)
edges = append(edges, k)
}
if err := rows.Err(); err != nil {
rows.Close()
@ -182,11 +182,11 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string)
// Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende
// Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum.
rows, err = s.lesen.QueryContext(ctx,
rows, err = s.read.QueryContext(ctx,
`SELECT l.to_slug, e.slug
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`,
WHERE l.project_id = ? AND l.to_slug IN (`+placeholders+`) AND e.deleted_at IS NULL`,
args...,
)
if err != nil {
@ -194,12 +194,12 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string)
}
defer rows.Close()
for rows.Next() {
var k kante
if err := rows.Scan(&k.von, &k.nach); err != nil {
var k edge
if err := rows.Scan(&k.from, &k.to); err != nil {
return nil, err
}
k.relation = RelationLinkedBy
kanten = append(kanten, k)
edges = append(edges, k)
}
if err := rows.Err(); err != nil {
return nil, err
@ -207,36 +207,36 @@ func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string)
// Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
// (siehe RelatedEntries).
sort.Slice(kanten, func(i, j int) bool {
if kanten[i].nach != kanten[j].nach {
return kanten[i].nach < kanten[j].nach
sort.Slice(edges, func(i, j int) bool {
if edges[i].to != edges[j].to {
return edges[i].to < edges[j].to
}
if kanten[i].von != kanten[j].von {
return kanten[i].von < kanten[j].von
if edges[i].from != edges[j].from {
return edges[i].from < edges[j].from
}
return kanten[i].relation < kanten[j].relation
return edges[i].relation < edges[j].relation
})
return kanten, nil
return edges, nil
}
// eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
type eintragsKopf struct {
// entryHead ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
type entryHead struct {
Title string
UpdatedAt time.Time
}
// eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern.
// entryHeads lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern.
// Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort.
func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) {
koepfe := make(map[string]eintragsKopf, len(slugs))
func (s *Store) entryHeads(ctx context.Context, projectID string, slugs []string) (map[string]entryHead, error) {
heads := make(map[string]entryHead, len(slugs))
if len(slugs) == 0 {
return koepfe, nil
return heads, nil
}
platz, args := inListe(projectID, slugs)
rows, err := s.lesen.QueryContext(ctx,
placeholders, args := inList(projectID, slugs)
rows, err := s.read.QueryContext(ctx,
`SELECT slug, title, updated_at FROM entries
WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb,
WHERE project_id = ? AND slug IN (`+placeholders+`) AND `+notInTrash,
args...,
)
if err != nil {
@ -245,26 +245,26 @@ func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []st
defer rows.Close()
for rows.Next() {
var slug, title, geaendert string
if err := rows.Scan(&slug, &title, &geaendert); err != nil {
var slug, title, changed string
if err := rows.Scan(&slug, &title, &changed); err != nil {
return nil, err
}
koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)}
heads[slug] = entryHead{Title: title, UpdatedAt: parseTime(changed)}
}
return koepfe, rows.Err()
return heads, rows.Err()
}
// inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden
// inList baut die Platzhalter für "IN (?, ?, …)" und die passenden
// Argumente, mit der Projekt-ID vorneweg.
//
// Die Werte werden nie in den SQL-Text geschrieben, sondern immer als
// Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die
// Abfrage verändert (SQL-Injection).
func inListe(projectID string, werte []string) (string, []any) {
args := make([]any, 0, len(werte)+1)
func inList(projectID string, values []string) (string, []any) {
args := make([]any, 0, len(values)+1)
args = append(args, projectID)
for _, w := range werte {
for _, w := range values {
args = append(args, w)
}
return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args
return strings.TrimSuffix(strings.Repeat("?, ", len(values)), ", "), args
}

View file

@ -7,8 +7,8 @@ import (
"time"
)
// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab.
func schreibe(t *testing.T, s *Store, projectID, slug, content string) {
// write legt einen Eintrag an und bricht den Test bei einem Fehler ab.
func write(t *testing.T, s *Store, projectID, slug, content string) {
t.Helper()
if _, err := s.PutEntry(context.Background(), EntryWrite{
ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content,
@ -29,24 +29,24 @@ func TestRelatedEntries(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
schreibe(t, s, p.ID, "a", "weiter bei [[fern]]")
schreibe(t, s, p.ID, "b", "hängt an [[start]]")
schreibe(t, s, p.ID, "fern", "Ende")
schreibe(t, s, p.ID, "c", "allein")
write(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
write(t, s, p.ID, "a", "weiter bei [[fern]]")
write(t, s, p.ID, "b", "hängt an [[start]]")
write(t, s, p.ID, "fern", "Ende")
write(t, s, p.ID, "c", "allein")
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
related, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
gefunden := make(map[string]RelatedEntry)
for _, v := range verwandte {
gefunden[v.Slug] = v
found := make(map[string]RelatedEntry)
for _, v := range related {
found[v.Slug] = v
}
erwartet := map[string]struct {
abstand int
want := map[string]struct {
distance int
via string
relation string
}{
@ -54,15 +54,15 @@ func TestRelatedEntries(t *testing.T) {
"b": {1, "start", RelationLinkedBy},
"fern": {2, "a", RelationLinksTo},
}
for slug, e := range erwartet {
v, ok := gefunden[slug]
for slug, e := range want {
v, ok := found[slug]
if !ok {
t.Errorf("%q fehlt", slug)
continue
}
if v.Distance != e.abstand || v.Via != e.via || v.Relation != e.relation {
if v.Distance != e.distance || v.Via != e.via || v.Relation != e.relation {
t.Errorf("%q: Abstand %d über %q (%s), erwartet %d über %q (%s)",
slug, v.Distance, v.Via, v.Relation, e.abstand, e.via, e.relation)
slug, v.Distance, v.Via, v.Relation, e.distance, e.via, e.relation)
}
if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() {
t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v)
@ -70,32 +70,32 @@ func TestRelatedEntries(t *testing.T) {
}
for _, slug := range []string{"start", "fehlt", "c"} {
if _, ok := gefunden[slug]; ok {
if _, ok := found[slug]; ok {
t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug)
}
}
// Näheres vor Fernerem.
if verwandte[len(verwandte)-1].Slug != "fern" {
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte)
if related[len(related)-1].Slug != "fern" {
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", related)
}
// Mit Abstand 1 nur die direkten Nachbarn.
direkt, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20)
direct, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20)
if err != nil {
t.Fatal(err)
}
if len(direkt) != 2 {
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt)
if len(direct) != 2 {
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direct), direct)
}
// Die Obergrenze gilt.
begrenzt, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1)
limited, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1)
if err != nil {
t.Fatal(err)
}
if len(begrenzt) != 1 {
t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt))
if len(limited) != 1 {
t.Errorf("Limit 1 lieferte %d Einträge", len(limited))
}
}
@ -106,20 +106,20 @@ func TestRelatedEntriesSkipsTrash(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[weg]]")
schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]")
schreibe(t, s, p.ID, "dahinter", "Ende")
write(t, s, p.ID, "start", "siehe [[weg]]")
write(t, s, p.ID, "weg", "siehe [[dahinter]]")
write(t, s, p.ID, "dahinter", "Ende")
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil {
t.Fatal(err)
}
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
related, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
if len(verwandte) != 0 {
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte)
if len(related) != 0 {
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", related)
}
// Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden.
@ -139,22 +139,22 @@ func TestRelatedEntriesUnknownSlug(t *testing.T) {
// TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5).
func TestEntryStale(t *testing.T) {
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
faelle := []struct {
name string
geaendert time.Time
erwartet bool
cases := []struct {
name string
changed time.Time
want bool
}{
{"gestern", jetzt.AddDate(0, 0, -1), false},
{"vor elf Monaten", jetzt.AddDate(0, -11, 0), false},
{"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true},
{"gestern", now.AddDate(0, 0, -1), false},
{"vor elf Monaten", now.AddDate(0, -11, 0), false},
{"vor zwei Jahren", now.AddDate(-2, 0, 0), true},
{"ohne Datum", time.Time{}, false},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet {
t.Errorf("Stale = %v, erwartet %v", got, f.erwartet)
if got := (Entry{UpdatedAt: f.changed}).Stale(now); got != f.want {
t.Errorf("Stale = %v, erwartet %v", got, f.want)
}
})
}
@ -167,31 +167,31 @@ func TestPutEntryKeepsImportedTimestamps(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
alt := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC)
old := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC)
e, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x",
Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt,
Source: SourceHuman, CreatedAt: old, UpdatedAt: old,
})
if err != nil {
t.Fatal(err)
}
gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt")
if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) {
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt)
read, _ := s.EntryBySlug(ctx, p.ID, "alt")
if !read.CreatedAt.Equal(old) || !read.UpdatedAt.Equal(old) || !e.UpdatedAt.Equal(old) {
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", read.CreatedAt, read.UpdatedAt)
}
// Eine Änderung passiert jetzt, egal was mitgegeben wird.
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu",
Source: SourceHuman, UpdatedAt: alt,
Source: SourceHuman, UpdatedAt: old,
}); err != nil {
t.Fatal(err)
}
gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt")
if gelesen.UpdatedAt.Equal(alt) {
read, _ = s.EntryBySlug(ctx, p.ID, "alt")
if read.UpdatedAt.Equal(old) {
t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen")
}
if !gelesen.CreatedAt.Equal(alt) {
if !read.CreatedAt.Equal(old) {
t.Error("beim Ändern ging das Anlegedatum verloren")
}
}

View file

@ -1,7 +1,7 @@
package store
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
// (migrations/006_suchprotokoll.sql).
// (migrations/006_search_log.sql).
import (
"context"
@ -32,7 +32,7 @@ type SearchLogEntry struct {
// Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die
// Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) {
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results)
VALUES (?, ?, ?, ?, ?, ?)`,
NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results,
@ -44,8 +44,8 @@ func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) {
// PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind.
// Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit.
func (s *Store) PruneSearchLog(ctx context.Context, aelterAls string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls)
func (s *Store) PruneSearchLog(ctx context.Context, olderThan string) (int64, error) {
res, err := s.write.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, olderThan)
if err != nil {
return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err)
}
@ -61,29 +61,29 @@ type SearchStats struct {
// EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100).
func (s SearchStats) EmptyPercent() int {
return prozent(s.Empty, s.Total)
return percent(s.Empty, s.Total)
}
// SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100).
// Zeigt, ob die Agenten die Bitte aus den instructions befolgen.
func (s SearchStats) SynonymPercent() int {
return prozent(s.WithSynonyms, s.Total)
return percent(s.WithSynonyms, s.Total)
}
// SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, now time.Time) (SearchStats, error) {
since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine
// 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert.
var st SearchStats
err := s.lesen.QueryRowContext(ctx,
err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0)
FROM search_log
WHERE project_id = ? AND ts >= ?`,
projectID, seit,
projectID, since,
).Scan(&st.Total, &st.Empty, &st.WithSynonyms)
if err != nil {
return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err)
@ -106,8 +106,8 @@ type EmptySearch struct {
// Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung
// zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über"
// bleiben also zwei Zeilen. Für eine Übersicht reicht das.
func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
func (s *Store) EmptySearches(ctx context.Context, projectID string, now time.Time, limit int) ([]EmptySearch, error) {
since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Je Anfrage (ohne Rücksicht auf Groß-/Kleinschreibung und Leerzeichen am
// Rand) eine Zeile: die jüngste, mit ihren Synonymen. COUNT(*) OVER zählt
@ -117,7 +117,7 @@ func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.
// Früher stand hier MAX(ts) mit Spalten ohne Aggregat — eine Eigenheit von
// SQLite, die PostgreSQL ablehnt. Fensterfunktionen können beide (siehe
// CuratedOverrides und AUSBAUSTUFEN.md 4.10).
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT query, synonyms, anzahl, ts FROM (
SELECT query, synonyms, ts,
COUNT(*) OVER (PARTITION BY lower(trim(query))) AS anzahl,
@ -128,22 +128,22 @@ func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.
WHERE nr = 1
ORDER BY anzahl DESC, ts DESC
LIMIT ?`,
projectID, seit, limit,
projectID, since, limit,
)
if err != nil {
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []EmptySearch
var list []EmptySearch
for rows.Next() {
var e EmptySearch
var zuletzt string
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil {
var latest string
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &latest); err != nil {
return nil, err
}
e.Last = parseTime(zuletzt)
liste = append(liste, e)
e.Last = parseTime(latest)
list = append(list, e)
}
return liste, rows.Err()
return list, rows.Err()
}

View file

@ -6,9 +6,9 @@ import (
"time"
)
// TestSearchStatsZaehlt prüft die Kennzahlen: alle Suchen, ohne Treffer, mit
// TestSearchStatsCounts prüft die Kennzahlen: alle Suchen, ohne Treffer, mit
// Synonymen — und nur innerhalb des Zeitfensters und des Projekts.
func TestSearchStatsZaehlt(t *testing.T) {
func TestSearchStatsCounts(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -18,9 +18,9 @@ func TestSearchStatsZaehlt(t *testing.T) {
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0})
// Außerhalb des Fensters: darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil {
t.Fatal(err)
}
@ -36,8 +36,8 @@ func TestSearchStatsZaehlt(t *testing.T) {
}
}
// TestSearchStatsLeer prüft ein Projekt ohne Suchen: alles 0, kein Fehler.
func TestSearchStatsLeer(t *testing.T) {
// TestSearchStatsEmpty prüft ein Projekt ohne Suchen: alles 0, kein Fehler.
func TestSearchStatsEmpty(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
@ -50,10 +50,10 @@ func TestSearchStatsLeer(t *testing.T) {
}
}
// TestEmptySearchesFasstZusammen prüft, dass gleiche Anfragen ohne Treffer
// TestEmptySearchesAggregates prüft, dass gleiche Anfragen ohne Treffer
// zusammengefasst werden, unabhängig von Groß- und Kleinschreibung, und die
// häufigste vorne steht.
func TestEmptySearchesFasstZusammen(t *testing.T) {
func TestEmptySearchesAggregates(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -64,21 +64,21 @@ func TestEmptySearchesFasstZusammen(t *testing.T) {
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "drucker", Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 2}) // hat Treffer
liste, err := s.EmptySearches(ctx, p.ID, time.Now(), 10)
list, err := s.EmptySearches(ctx, p.ID, time.Now(), 10)
if err != nil {
t.Fatal(err)
}
if len(liste) != 2 {
t.Fatalf("%d Anfragen, erwartet 2: %+v", len(liste), liste)
if len(list) != 2 {
t.Fatalf("%d Anfragen, erwartet 2: %+v", len(list), list)
}
if liste[0].Count != 3 {
t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", liste[0])
if list[0].Count != 3 {
t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", list[0])
}
if liste[0].Synonyms != "remote" {
t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", liste[0].Synonyms)
if list[0].Synonyms != "remote" {
t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", list[0].Synonyms)
}
if liste[1].Query != "drucker" {
t.Errorf("zweite Anfrage = %q, erwartet drucker", liste[1].Query)
if list[1].Query != "drucker" {
t.Errorf("zweite Anfrage = %q, erwartet drucker", list[1].Query)
}
}
@ -88,15 +88,15 @@ func TestPruneSearchLog(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
alt := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
old := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano)
if _, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, old, p.ID); err != nil {
t.Fatal(err)
}
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0})
grenze := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano)
n, err := s.PruneSearchLog(ctx, grenze)
limit := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano)
n, err := s.PruneSearchLog(ctx, limit)
if err != nil {
t.Fatal(err)
}
@ -105,9 +105,9 @@ func TestPruneSearchLog(t *testing.T) {
}
}
// TestSuchprotokollVerschwindetMitProjekt prüft den Fremdschlüssel: Anfragen
// TestSearchLogVanishesWithProject prüft den Fremdschlüssel: Anfragen
// sind Inhalt des Projekts und gehen mit ihm.
func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) {
func TestSearchLogVanishesWithProject(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -117,11 +117,11 @@ func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) {
t.Fatal(err)
}
var anzahl int
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&anzahl); err != nil {
var count int
if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&count); err != nil {
t.Fatal(err)
}
if anzahl != 0 {
t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", anzahl)
if count != 0 {
t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", count)
}
}

View file

@ -1,81 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// SQLiteSpeicher legt die Kontroll-Datenbank und jeden Workspace als eigene
// SQLite-Datei ab. Das ist der Standard und der bisherige Aufbau
// (AUSBAUSTUFEN.md 4.4).
type SQLiteSpeicher struct {
controlPfad string // z.B. "<DATA_DIR>/control.db"
workspaceOrdner string // z.B. "<DATA_DIR>/workspaces", muss existieren
}
// NewSQLite legt einen SQLite-Speicher an. Der Workspace-Ordner muss bereits
// existieren — das übernimmt main.go beim Start.
//
// Wer nur Workspaces braucht (etwa ein Test des workspace.Manager), kann
// controlPfad leer lassen; OpenControl meldet dann einen Fehler.
func NewSQLite(controlPfad, workspaceOrdner string) *SQLiteSpeicher {
return &SQLiteSpeicher{controlPfad: controlPfad, workspaceOrdner: workspaceOrdner}
}
func (s *SQLiteSpeicher) OpenControl() (*ControlStore, error) {
if s.controlPfad == "" {
return nil, errors.New("SQLite-Speicher ohne Pfad zur Kontroll-Datenbank")
}
return OpenControl(s.controlPfad)
}
func (s *SQLiteSpeicher) OpenWorkspace(workspaceID string) (*Store, error) {
return Open(s.workspaceDatei(workspaceID))
}
// DeleteWorkspace entfernt die Workspace-Datei unwiederbringlich von der
// Platte — samt WAL/SHM-Nebendateien, die SQLite im laufenden Betrieb
// danebenlegt.
func (s *SQLiteSpeicher) DeleteWorkspace(workspaceID string) error {
pfad := s.workspaceDatei(workspaceID)
for _, endung := range []string{"", "-wal", "-shm"} {
if err := os.Remove(pfad + endung); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("Workspace-Datei konnte nicht gelöscht werden: %w", err)
}
}
return nil
}
// PruefeKapazitaet: Jede SQLite-Datei hat ihre eigenen Verbindungen, eine
// gemeinsame Obergrenze gibt es nicht.
func (s *SQLiteSpeicher) PruefeKapazitaet(context.Context, int) error { return nil }
// Kapazitaet: SQLite kennt keine gemeinsame Grenze.
func (s *SQLiteSpeicher) Kapazitaet(context.Context) (Kapazitaet, error) {
return Kapazitaet{}, nil
}
// Ort nennt die Datei relativ zum Datenverzeichnis, z.B.
// "workspaces/mue9bh3x-1c0e1120.db" — der volle Pfad stünde schon im
// Startprotokoll und wäre in der Tabelle nur lang.
func (s *SQLiteSpeicher) Ort(workspaceID string) string {
return filepath.Join(filepath.Base(s.workspaceOrdner), workspaceID+".db")
}
func (s *SQLiteSpeicher) Beschreibung() string {
return fmt.Sprintf("SQLite — Workspaces: %s, Kontrolle: %s", s.workspaceOrdner, s.controlPfad)
}
// Hinweise: Beim SQLite-Speicher gibt es beim Start nichts zu prüfen. Eine
// zu große WAL-Datei zeigt die Systemseite je Workspace (Store.Belegung).
func (s *SQLiteSpeicher) Hinweise() []string { return nil }
// Close: Der SQLite-Speicher hält selbst nichts offen.
func (s *SQLiteSpeicher) Close() error { return nil }
func (s *SQLiteSpeicher) workspaceDatei(workspaceID string) string {
return filepath.Join(s.workspaceOrdner, workspaceID+".db")
}

View file

@ -13,9 +13,9 @@
// kann. Die Regeln stehen als Kommentar in migrations/sqlite/001_init.sql.
//
// Seit AUSBAUSTUFEN.md 4.10 läuft derselbe Store wahlweise auf SQLite oder
// PostgreSQL. Was sich unterscheidet, steht im dialekt (dialekt.go); welche
// Datenbank benutzt wird, entscheidet der Speicher (speicher.go). Alle
// Abfragen laufen über pool (verbindung.go), damit keine am Dialekt
// PostgreSQL. Was sich unterscheidet, steht im dialect (dialect.go); welche
// Datenbank benutzt wird, entscheidet das Backend (backend.go). Alle
// Abfragen laufen über pool (pool.go), damit keine am Dialekt
// vorbeikommt.
//
// # Warum modernc.org/sqlite?
@ -77,10 +77,10 @@ var migrationsFS embed.FS
// die Leser sind davon nicht betroffen.
//
// Unter PostgreSQL gilt dieselbe Aufteilung, nur mit anderen Größen (siehe
// speicher_postgres.go).
// backend_postgres.go).
type Store struct {
lesen pool // mehrere Verbindungen — nur lesende Abfragen
schreiben pool // genau eine Verbindung — alles, was verändert
read pool // mehrere Verbindungen — nur lesende Abfragen
write pool // genau eine Verbindung — alles, was verändert
}
// Open öffnet die Workspace-Datenbankdatei, setzt die nötigen Einstellungen und
@ -90,14 +90,14 @@ type Store struct {
// sich um die einzige Abteilung handelt oder eine von vierzig.
//
// Open ist der Weg für SQLite. Welche Datenbank der Server benutzt, entscheidet
// der Speicher (speicher.go); Tests und Werkzeuge, die einfach eine Datei
// der Speicher (backend.go); Tests und Werkzeuge, die einfach eine Datei
// brauchen, rufen Open direkt auf.
func Open(path string) (*Store, error) {
lesen, schreiben, err := openSQLite(path, migrationsFS, "migrations")
read, write, err := openSQLite(path, migrationsFS, "migrations")
if err != nil {
return nil, err
}
return &Store{lesen: lesen, schreiben: schreiben}, nil
return &Store{read: read, write: write}, nil
}
// openSQLite öffnet eine SQLite-Datei mit getrenntem Lese- und Schreibpool und
@ -105,9 +105,9 @@ func Open(path string) (*Store, error) {
// (Workspace-Dateien) und OpenControl (Kontroll-Datenbank) — beide brauchen
// exakt dieselben Einstellungen, nur unterschiedliche Dateien und Migrationen.
//
// Das Gegenstück für PostgreSQL ist PostgresSpeicher.oeffnePools.
func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen, schreiben pool, err error) {
d := sqliteDialekt{}
// Das Gegenstück für PostgreSQL ist PostgresBackend.oeffnePools.
func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (read, write pool, err error) {
d := sqliteDialect{}
// Die Parameter in der URL setzen drei Dinge, ohne die SQLite unter
// gleichzeitigen Zugriffen unangenehm wird:
@ -136,60 +136,60 @@ func openSQLite(path string, migrationsFS embed.FS, migrationsDir string) (lesen
// weil SQLite den Wartenden nicht zurücksetzen kann, ohne die Transaktion
// abzubrechen. Genau daran scheiterten 296 von 320 gleichzeitigen
// Schreibvorgängen in einer früheren Messung (siehe AUSBAUSTUFEN.md).
schreibDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
writeDB, err := sql.Open("sqlite", dsn+"&_txlock=immediate")
if err != nil {
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht geöffnet werden: %w", err)
}
schreibDB.SetMaxOpenConns(1)
schreiben = pool{db: schreibDB, d: d}
writeDB.SetMaxOpenConns(1)
write = pool{db: writeDB, d: d}
if err := schreibDB.Ping(); err != nil {
schreibDB.Close()
if err := writeDB.Ping(); err != nil {
writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht erreichbar: %w", err)
}
if err := runMigrations(schreiben, migrationsFS, migrationsDir); err != nil {
schreibDB.Close()
if err := runMigrations(write, migrationsFS, migrationsDir); err != nil {
writeDB.Close()
return pool{}, pool{}, err
}
// Der Lesepool wird erst danach geöffnet — dann steht das Schema bereits.
leseDB, err := sql.Open("sqlite", dsn)
readDB, err := sql.Open("sqlite", dsn)
if err != nil {
schreibDB.Close()
writeDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank konnte nicht zum Lesen geöffnet werden: %w", err)
}
leseDB.SetMaxOpenConns(maxLeseverbindungen)
lesen = pool{db: leseDB, d: d}
readDB.SetMaxOpenConns(maxReadConns)
read = pool{db: readDB, d: d}
if err := leseDB.Ping(); err != nil {
schreibDB.Close()
leseDB.Close()
if err := readDB.Ping(); err != nil {
writeDB.Close()
readDB.Close()
return pool{}, pool{}, fmt.Errorf("Datenbank ist nicht lesbar: %w", err)
}
return lesen, schreiben, nil
return read, write, nil
}
// maxLeseverbindungen begrenzt die gleichzeitigen Lesezugriffe.
// maxReadConns begrenzt die gleichzeitigen Lesezugriffe.
//
// Mehr als eine Handvoll bringt nichts: Ab etwa acht Verbindungen ist der
// Durchsatz erreicht, weil dann die Rechenkerne und nicht mehr das Warten die
// Grenze setzen. Jede Verbindung kostet dafür Arbeitsspeicher.
const maxLeseverbindungen = 8
const maxReadConns = 8
// Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen).
func (s *Store) Verbindungen() Verbindungen {
return Verbindungen{Lesen: s.lesen.db.Stats(), Schreiben: s.schreiben.db.Stats()}
// PoolStats liefert die Zahlen beider Pools (siehe Verbindungen).
func (s *Store) PoolStats() PoolStats {
return PoolStats{Read: s.read.db.Stats(), Write: s.write.db.Stats()}
}
// Close schließt beide Verbindungspools.
func (s *Store) Close() error {
fehlerLesen := s.lesen.Close()
if err := s.schreiben.Close(); err != nil {
readErr := s.read.Close()
if err := s.write.Close(); err != nil {
return err
}
return fehlerLesen
return readErr
}
// runMigrations wendet alle noch nicht ausgeführten Migrationen aus
@ -214,11 +214,11 @@ func (s *Store) Close() error {
// In schema_migrations steht nur der Dateiname ("001_init.sql"), nicht der
// Ordner. Deshalb hat das Aufteilen in sqlite/ und postgres/ bestehende
// Datenbanken nicht berührt.
func runMigrations(schreiben pool, fsys embed.FS, dir string) error {
func runMigrations(write pool, fsys embed.FS, dir string) error {
ctx := context.Background()
dir = dir + "/" + schreiben.d.migrationsUnterordner()
dir = dir + "/" + write.d.migrationsSubdir()
_, err := schreiben.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
_, err := write.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
@ -226,27 +226,27 @@ func runMigrations(schreiben pool, fsys embed.FS, dir string) error {
return fmt.Errorf("Migrationstabelle konnte nicht angelegt werden: %w", err)
}
angewandt, err := appliedMigrations(schreiben)
applied, err := appliedMigrations(write)
if err != nil {
return err
}
dateien, err := migrationFiles(fsys, dir)
files, err := migrationFiles(fsys, dir)
if err != nil {
return err
}
for _, name := range dateien {
if angewandt[name] {
for _, name := range files {
if applied[name] {
continue
}
inhalt, err := fsys.ReadFile(dir + "/" + name)
content, err := fsys.ReadFile(dir + "/" + name)
if err != nil {
return fmt.Errorf("Migration %s konnte nicht gelesen werden: %w", name, err)
}
if err := applyMigration(schreiben, name, string(inhalt)); err != nil {
if err := applyMigration(write, name, string(content)); err != nil {
return err
}
}
@ -255,42 +255,42 @@ func runMigrations(schreiben pool, fsys embed.FS, dir string) error {
}
// appliedMigrations liest die Namen der bereits ausgeführten Migrationen.
func appliedMigrations(schreiben pool) (map[string]bool, error) {
rows, err := schreiben.QueryContext(context.Background(), `SELECT name FROM schema_migrations`)
func appliedMigrations(write pool) (map[string]bool, error) {
rows, err := write.QueryContext(context.Background(), `SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
angewandt := make(map[string]bool)
applied := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
angewandt[name] = true
applied[name] = true
}
return angewandt, rows.Err()
return applied, rows.Err()
}
// migrationFiles listet die Migrationsdateien in aufsteigender Reihenfolge.
func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
eintraege, err := fsys.ReadDir(dir)
entries, err := fsys.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("Migrationen konnten nicht gelesen werden: %w", err)
}
var namen []string
for _, e := range eintraege {
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
namen = append(namen, e.Name())
names = append(names, e.Name())
}
}
// Die Nummerierung am Anfang macht die alphabetische Sortierung zur
// richtigen Reihenfolge.
sort.Strings(namen)
return namen, nil
sort.Strings(names)
return names, nil
}
// applyMigration führt eine einzelne Migration in einer Transaktion aus.
@ -300,8 +300,8 @@ func migrationFiles(fsys embed.FS, dir string) ([]string, error) {
// Fragezeichen, und die gehören dort hin. Ohne Parameter schickt auch der
// PostgreSQL-Treiber den Text unverändert und erlaubt dabei mehrere
// Anweisungen hintereinander.
func applyMigration(schreiben pool, name, sqlText string) error {
tx, err := schreiben.db.Begin()
func applyMigration(write pool, name, sqlText string) error {
tx, err := write.db.Begin()
if err != nil {
return fmt.Errorf("Transaktion für %s konnte nicht gestartet werden: %w", name, err)
}
@ -314,7 +314,7 @@ func applyMigration(schreiben pool, name, sqlText string) error {
}
if _, err := tx.Exec(
schreiben.d.rebind(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`),
write.d.rebind(`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`),
name, NowString(),
); err != nil {
return fmt.Errorf("Migration %s konnte nicht vermerkt werden: %w", name, err)
@ -327,8 +327,8 @@ func applyMigration(schreiben pool, name, sqlText string) error {
//
// Damit steht die Fehlerbehandlung einmal hier statt in jeder schreibenden
// Methode — und es kann nicht passieren, dass irgendwo ein Rollback vergessen wird.
func (s *Store) tx(ctx context.Context, fn func(*transaktion) error) error {
tx, err := s.schreiben.beginTx(ctx)
func (s *Store) tx(ctx context.Context, fn func(*transaction) error) error {
tx, err := s.write.beginTx(ctx)
if err != nil {
return fmt.Errorf("Transaktion konnte nicht gestartet werden: %w", err)
}

View file

@ -22,7 +22,7 @@ import (
// eigenen Schema (siehe postgres_test.go).
func testStore(t *testing.T) *Store {
t.Helper()
if pgTestAktiv() {
if pgTestEnabled() {
return testPostgresStore(t)
}
@ -61,16 +61,16 @@ func testProject(t *testing.T, s *Store) Project {
// TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank
// nicht kaputt macht — der Normalfall bei jedem Neustart des Servers.
func TestMigrationsAreIdempotent(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "test.db")
filePath := filepath.Join(t.TempDir(), "test.db")
s1, err := Open(pfad)
s1, err := Open(filePath)
if err != nil {
t.Fatalf("erster Start: %v", err)
}
p := testProject(t, s1)
s1.Close()
s2, err := Open(pfad)
s2, err := Open(filePath)
if err != nil {
t.Fatalf("zweiter Start: %v", err)
}
@ -82,12 +82,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
}
}
// TestMigrationBestandsdaten prüft, dass eine mit Fassung 001 angelegte
// TestMigrationExistingData prüft, dass eine mit Fassung 001 angelegte
// Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update.
func TestMigrationBestandsdaten(t *testing.T) {
pfad := filepath.Join(t.TempDir(), "alt.db")
func TestMigrationExistingData(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "alt.db")
s, err := Open(pfad)
s, err := Open(filePath)
if err != nil {
t.Fatal(err)
}
@ -101,18 +101,18 @@ func TestMigrationBestandsdaten(t *testing.T) {
s.Close()
// Erneut öffnen — hier läuft die Migration bei Bestandsdaten.
s2, err := Open(pfad)
s2, err := Open(filePath)
if err != nil {
t.Fatalf("Migration fehlgeschlagen: %v", err)
}
defer s2.Close()
// project_id muss in beiden Nebentabellen nachgetragen worden sein.
var leereTags, leereLinks int
s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags)
s2.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks)
if leereTags != 0 || leereLinks != 0 {
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks)
var emptyTags, emptyLinks int
s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&emptyTags)
s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&emptyLinks)
if emptyTags != 0 || emptyLinks != 0 {
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", emptyTags, emptyLinks)
}
// Und die Abfragen liefern weiterhin das Richtige.
@ -126,29 +126,29 @@ func TestMigrationBestandsdaten(t *testing.T) {
}
}
// TestGleichzeitigeSchreibvorgaenge ist der Regressionstest zu den zwei
// TestConcurrentWrites ist der Regressionstest zu den zwei
// Verbindungspools (siehe Store in store.go).
//
// Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von
// 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen
// gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre,
// wo busy_timeout nicht mehr greift.
func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
func TestConcurrentWrites(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
ctx := context.Background()
const agenten, jeAgent = 8, 40
const agents, perAgent = 8, 40
var wg sync.WaitGroup
var mu sync.Mutex
var fehler []error
var errs []error
for a := 0; a < agenten; a++ {
for a := 0; a < agents; a++ {
wg.Add(1)
go func(a int) {
defer wg.Done()
for i := 0; i < jeAgent; i++ {
for i := 0; i < perAgent; i++ {
_, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID,
Slug: fmt.Sprintf("agent%d-%03d", a, i),
@ -159,7 +159,7 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
})
if err != nil {
mu.Lock()
fehler = append(fehler, err)
errs = append(errs, err)
mu.Unlock()
}
}
@ -167,23 +167,23 @@ func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
}
wg.Wait()
if len(fehler) > 0 {
if len(errs) > 0 {
t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v",
len(fehler), agenten*jeAgent, fehler[0])
len(errs), agents*perAgent, errs[0])
}
n, err := s.CountEntries(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if n != agenten*jeAgent {
t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent)
if n != agents*perAgent {
t.Errorf("%d Einträge angelegt, erwartet %d", n, agents*perAgent)
}
}
// TestGleichzeitigesLesenUndSchreiben prüft, dass Lesezugriffe nicht hinter
// TestConcurrentReadAndWrite prüft, dass Lesezugriffe nicht hinter
// Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool.
func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
func TestConcurrentReadAndWrite(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
ctx := context.Background()
@ -194,7 +194,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
}
var wg sync.WaitGroup
var leseFehler, schreibFehler int64
var readErr, writeErr int64
// Schreiber und Leser gleichzeitig laufen lassen.
for i := 0; i < 4; i++ {
@ -205,7 +205,7 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID,
Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T",
Content: "Inhalt", Source: SourceAgent}); err != nil {
atomic.AddInt64(&schreibFehler, 1)
atomic.AddInt64(&writeErr, 1)
}
}
}(i)
@ -213,15 +213,15 @@ func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
defer wg.Done()
for j := 0; j < 100; j++ {
if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil {
atomic.AddInt64(&leseFehler, 1)
atomic.AddInt64(&readErr, 1)
}
}
}()
}
wg.Wait()
if leseFehler > 0 || schreibFehler > 0 {
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler)
if readErr > 0 || writeErr > 0 {
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", readErr, writeErr)
}
}
@ -279,29 +279,29 @@ func TestUpdateProject(t *testing.T) {
t.Fatalf("Projekt ändern: %v", err)
}
gelesen, err := s.ProjectByID(ctx, p.ID)
read, err := s.ProjectByID(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" {
t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description)
if read.Name != "Neuer Name" || read.Description != "Neue Beschreibung" {
t.Errorf("Name/Beschreibung = %q/%q", read.Name, read.Description)
}
if gelesen.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly)
if read.WriteLimitHourly != 250 {
t.Errorf("WriteLimitHourly = %d, erwartet 250", read.WriteLimitHourly)
}
// Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen.
if gelesen.Slug != p.Slug {
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.Slug, p.Slug)
if read.Slug != p.Slug {
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", read.Slug, p.Slug)
}
// 0 nimmt die eigene Obergrenze zurück.
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil {
t.Fatal(err)
}
gelesen, _ = s.ProjectByID(ctx, p.ID)
if gelesen.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly)
read, _ = s.ProjectByID(ctx, p.ID)
if read.WriteLimitHourly != 0 {
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", read.WriteLimitHourly)
}
}
@ -383,16 +383,16 @@ func TestResolveKeyHash(t *testing.T) {
t.Fatal(err)
}
auf, err := s.ResolveKeyHash(ctx, "hash-abc")
resolved, err := s.ResolveKeyHash(ctx, "hash-abc")
if err != nil {
t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err)
}
if auf.Key.ID != k.ID {
if resolved.Key.ID != k.ID {
t.Error("falscher Key aufgelöst")
}
// Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request
// einen zweiten Datenbankzugriff.
if auf.Project.ID != p.ID {
if resolved.Project.ID != p.ID {
t.Error("das Projekt wurde nicht mitgeliefert")
}
}
@ -426,26 +426,26 @@ func TestRevokedKeyIsGone(t *testing.T) {
// TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7).
func TestAPIKeyUnused(t *testing.T) {
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) }
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
faelle := []struct {
name string
key APIKey
erwartet bool
cases := []struct {
name string
key APIKey
want bool
}{
{"gestern benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(1)}, false},
{"vor 91 Tagen benutzt", APIKey{CreatedAt: tage(400), LastUsedAt: tage(91)}, true},
{"genau 90 Tage", APIKey{CreatedAt: tage(400), LastUsedAt: tage(90)}, false},
{"nie benutzt, frisch angelegt", APIKey{CreatedAt: tage(3)}, false},
{"nie benutzt, lange angelegt", APIKey{CreatedAt: tage(120)}, true},
{"zurückgezogen", APIKey{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false},
{"gestern benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(1)}, false},
{"vor 91 Tagen benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(91)}, true},
{"genau 90 Tage", APIKey{CreatedAt: days(400), LastUsedAt: days(90)}, false},
{"nie benutzt, frisch angelegt", APIKey{CreatedAt: days(3)}, false},
{"nie benutzt, lange angelegt", APIKey{CreatedAt: days(120)}, true},
{"zurückgezogen", APIKey{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
if got := f.key.Unused(jetzt); got != f.erwartet {
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet)
if got := f.key.Unused(now); got != f.want {
t.Errorf("Unused = %v, erwartet %v", got, f.want)
}
})
}
@ -453,25 +453,25 @@ func TestAPIKeyUnused(t *testing.T) {
// TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys.
func TestWorkspaceAdminUnused(t *testing.T) {
jetzt := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
tage := func(n int) time.Time { return jetzt.Add(-time.Duration(n) * 24 * time.Hour) }
now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
faelle := []struct {
name string
admin WorkspaceAdmin
erwartet bool
cases := []struct {
name string
admin WorkspaceAdmin
want bool
}{
{"gestern benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(1)}, false},
{"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(91)}, true},
{"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: tage(3)}, false},
{"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: tage(120)}, true},
{"zurückgezogen", WorkspaceAdmin{CreatedAt: tage(400), LastUsedAt: tage(200), RevokedAt: tage(100)}, false},
{"gestern benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(1)}, false},
{"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(91)}, true},
{"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: days(3)}, false},
{"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: days(120)}, true},
{"zurückgezogen", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false},
}
for _, f := range faelle {
for _, f := range cases {
t.Run(f.name, func(t *testing.T) {
if got := f.admin.Unused(jetzt); got != f.erwartet {
t.Errorf("Unused = %v, erwartet %v", got, f.erwartet)
if got := f.admin.Unused(now); got != f.want {
t.Errorf("Unused = %v, erwartet %v", got, f.want)
}
})
}
@ -493,7 +493,7 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
erst, err := s.PutEntry(ctx, EntryWrite{
first, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "},
Source: SourceAgent, Author: "agent-1",
@ -501,27 +501,27 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if erst.Version != 1 {
t.Errorf("Version = %d, erwartet 1", erst.Version)
if first.Version != 1 {
t.Errorf("Version = %d, erwartet 1", first.Version)
}
// Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert.
if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" {
t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags)
if len(first.Tags) != 2 || first.Tags[0] != "deploy" || first.Tags[1] != "ops" {
t.Errorf("Tags = %v, erwartet [deploy ops]", first.Tags)
}
// Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der
// Dublettenvermeidung.
zweit, err := s.PutEntry(ctx, EntryWrite{
second, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2",
})
if err != nil {
t.Fatal(err)
}
if zweit.Version != 2 {
t.Errorf("Version = %d, erwartet 2", zweit.Version)
if second.Version != 2 {
t.Errorf("Version = %d, erwartet 2", second.Version)
}
if zweit.ID != erst.ID {
if second.ID != first.ID {
t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren")
}
if n, _ := s.CountEntries(ctx, p.ID); n != 1 {
@ -529,8 +529,8 @@ func TestPutEntryCreatesAndUpdates(t *testing.T) {
}
// Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine
// mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen.
if len(zweit.Tags) != 0 {
t.Errorf("Tags = %v, erwartet leer", zweit.Tags)
if len(second.Tags) != 0 {
t.Errorf("Tags = %v, erwartet leer", second.Tags)
}
}
@ -540,15 +540,15 @@ func TestPutEntryOptimisticLocking(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
basis := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent}
base := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent}
e, err := s.PutEntry(ctx, basis)
e, err := s.PutEntry(ctx, base)
if err != nil {
t.Fatal(err)
}
// Agent A schreibt auf Basis von Version 1 — geht durch.
a := basis
a := base
a.Content = "von A"
a.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, a); err != nil {
@ -556,7 +556,7 @@ func TestPutEntryOptimisticLocking(t *testing.T) {
}
// Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen.
b := basis
b := base
b.Content = "von B"
b.ExpectedVersion = e.Version
if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) {
@ -640,18 +640,18 @@ func TestListEntriesFilterByTag(t *testing.T) {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"docs"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"ops", "docs"}, Source: SourceHuman})
gefiltert, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"})
filtered, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"})
if err != nil {
t.Fatal(err)
}
if len(gefiltert) != 2 {
t.Errorf("%d Treffer, erwartet 2", len(gefiltert))
if len(filtered) != 2 {
t.Errorf("%d Treffer, erwartet 2", len(filtered))
}
// Groß-/Kleinschreibung darf keine Rolle spielen.
grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
if len(grossGeschrieben) != 2 {
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben))
capitalized, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
if len(capitalized) != 2 {
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(capitalized))
}
}
@ -707,7 +707,7 @@ func TestExtractLinks(t *testing.T) {
"[[a-b_c1]] ist erlaubt": {"a-b_c1"},
"einfache [klammern] sind kein Verweis": nil,
// In Code zählt nichts (siehe verweiseAusserhalbVonCode).
// In Code zählt nichts (siehe refsOutsideCode).
"Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"},
"doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"},
"```\n[[im-block]]\n```\n[[danach]]": {"danach"},
@ -719,29 +719,29 @@ func TestExtractLinks(t *testing.T) {
" [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"},
}
for input, erwartet := range cases {
for input, want := range cases {
got := ExtractLinks(input)
if len(got) != len(erwartet) {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
if len(got) != len(want) {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
continue
}
for i := range got {
if got[i] != erwartet[i] {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
if got[i] != want[i] {
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
break
}
}
}
}
// TestReplaceLinksLaesstCodeStehen prüft das Gegenstück beim Anzeigen: Ein
// TestReplaceLinksLeavesCodeAlone prüft das Gegenstück beim Anzeigen: Ein
// Verweis in Code bleibt Text, ein echter wird ersetzt.
func TestReplaceLinksLaesstCodeStehen(t *testing.T) {
ein := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n"
soll := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n"
got := ReplaceLinks(ein, func(slug string) string { return "<" + slug + ">" })
if got != soll {
t.Errorf("\nist: %q\nsoll: %q", got, soll)
func TestReplaceLinksLeavesCodeAlone(t *testing.T) {
in := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n"
expected := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n"
got := ReplaceLinks(in, func(slug string) string { return "<" + slug + ">" })
if got != expected {
t.Errorf("\nist: %q\nsoll: %q", got, expected)
}
}
@ -789,18 +789,18 @@ func TestAuditRoundTrip(t *testing.T) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete,
EntrySlug: "b", Source: SourceMCP})
ereignisse, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID})
events, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID})
if err != nil {
t.Fatal(err)
}
if len(ereignisse) != 2 {
t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse))
if len(events) != 2 {
t.Fatalf("%d Ereignisse, erwartet 2", len(events))
}
// Neueste zuerst.
if ereignisse[0].Action != ActionEntryDelete {
t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[0].Action, ActionEntryDelete)
if events[0].Action != ActionEntryDelete {
t.Errorf("erstes Ereignis = %q, erwartet %q", events[0].Action, ActionEntryDelete)
}
if ereignisse[0].Timestamp.IsZero() {
if events[0].Timestamp.IsZero() {
t.Error("Zeitstempel wurde nicht gesetzt")
}
}
@ -815,8 +815,8 @@ func TestPruneAudit(t *testing.T) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem})
// Grenze in der Zukunft — alles muss weg.
zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat)
n, err := s.PruneAudit(ctx, zukunft)
future := time.Now().UTC().Add(time.Hour).Format(timeFormat)
n, err := s.PruneAudit(ctx, future)
if err != nil {
t.Fatal(err)
}
@ -842,33 +842,33 @@ func TestSlugify(t *testing.T) {
"straße": "strasse",
}
for input, erwartet := range cases {
if got := Slugify(input); got != erwartet {
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet)
for input, want := range cases {
if got := Slugify(input); got != want {
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, want)
}
}
}
func TestNewIDIsSortableAndUnique(t *testing.T) {
gesehen := make(map[string]bool)
var vorher string
seen := make(map[string]bool)
var before string
for i := 0; i < 200; i++ {
id := NewID()
if gesehen[id] {
if seen[id] {
t.Fatalf("doppelte ID: %s", id)
}
gesehen[id] = true
seen[id] = true
// Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar.
if vorher != "" && id < vorher {
zeitVorher, _, _ := strings.Cut(vorher, "-")
zeitJetzt, _, _ := strings.Cut(id, "-")
if zeitJetzt < zeitVorher {
t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id)
if before != "" && id < before {
earlierTime, _, _ := strings.Cut(before, "-")
nowTime, _, _ := strings.Cut(id, "-")
if nowTime < earlierTime {
t.Errorf("IDs sind nicht chronologisch: %s vor %s", before, id)
}
}
vorher = id
before = id
}
}
@ -893,32 +893,32 @@ func TestBulkUpdateTags(t *testing.T) {
c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Tags: []string{"alt"}, Source: SourceHuman})
geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"})
changed, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"})
if err != nil {
t.Fatal(err)
}
if len(geaendert) != 2 {
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert))
if len(changed) != 2 {
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(changed))
}
neuA, _ := s.EntryBySlug(ctx, p.ID, "a")
if !stringSlicesEqual(neuA.Tags, []string{"neu"}) {
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags)
freshA, _ := s.EntryBySlug(ctx, p.ID, "a")
if !stringSlicesEqual(freshA.Tags, []string{"neu"}) {
t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags)
}
if neuA.Version != 2 {
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version)
if freshA.Version != 2 {
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", freshA.Version)
}
neuB, _ := s.EntryBySlug(ctx, p.ID, "b")
if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) {
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags)
freshB, _ := s.EntryBySlug(ctx, p.ID, "b")
if !stringSlicesEqual(freshB.Tags, []string{"bleibt", "neu"}) {
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", freshB.Tags)
}
unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c")
if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) {
t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags)
unchangedC, _ := s.EntryBySlug(ctx, p.ID, "c")
if !stringSlicesEqual(unchangedC.Tags, []string{"alt"}) {
t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unchangedC.Tags)
}
if unveraendertC.Version != c.Version {
if unchangedC.Version != c.Version {
t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war")
}
}
@ -931,23 +931,23 @@ func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) {
a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "")
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd",
foreign, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd",
Tags: []string{"x"}, Source: SourceHuman})
// Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts
// bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend
// Einträge verändern.
geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil)
changed, err := s.BulkUpdateTags(ctx, a.ID, []string{foreign.ID}, []string{"neu"}, nil)
if err != nil {
t.Fatal(err)
}
if len(geaendert) != 0 {
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert))
if len(changed) != 0 {
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(changed))
}
unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd")
if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) {
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags)
unchanged, _ := s.EntryBySlug(ctx, b.ID, "fremd")
if !stringSlicesEqual(unchanged.Tags, []string{"x"}) {
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unchanged.Tags)
}
}
@ -958,19 +958,19 @@ func TestPutEntryRecordsHistory(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung",
first, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung",
Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"})
if err != nil {
t.Fatal(err)
}
// Beim erstmaligen Anlegen gibt es noch nichts zu sichern.
historie, err := s.EntryVersions(ctx, erste.ID)
history, err := s.EntryVersions(ctx, first.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 0 {
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie))
if len(history) != 0 {
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(history))
}
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung",
@ -982,33 +982,33 @@ func TestPutEntryRecordsHistory(t *testing.T) {
t.Fatal(err)
}
historie, err = s.EntryVersions(ctx, erste.ID)
history, err = s.EntryVersions(ctx, first.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 2 {
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie))
if len(history) != 2 {
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(history))
}
// Neueste zuerst.
if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" {
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title)
if history[0].Version != 2 || history[0].Title != "Zweite Fassung" {
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", history[0].Version, history[0].Title)
}
if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" {
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title)
if history[1].Version != 1 || history[1].Title != "Erste Fassung" {
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", history[1].Version, history[1].Title)
}
if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) {
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags)
if history[1].Content != "Inhalt 1" || !stringSlicesEqual(history[1].Tags, []string{"a"}) {
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", history[1].Content, history[1].Tags)
}
fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1)
version1, err := s.EntryVersionByNumber(ctx, first.ID, 1)
if err != nil {
t.Fatal(err)
}
if fassung1.Content != "Inhalt 1" {
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content)
if version1.Content != "Inhalt 1" {
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", version1.Content)
}
if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) {
if _, err := s.EntryVersionByNumber(ctx, first.ID, 99); !errors.Is(err, ErrNotFound) {
t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err)
}
}
@ -1025,15 +1025,15 @@ func TestBulkUpdateTagsRecordsHistory(t *testing.T) {
t.Fatal(err)
}
historie, err := s.EntryVersions(ctx, e.ID)
history, err := s.EntryVersions(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 1 {
t.Fatalf("%d Fassungen, erwartet 1", len(historie))
if len(history) != 1 {
t.Fatalf("%d Fassungen, erwartet 1", len(history))
}
if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) {
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags)
if !stringSlicesEqual(history[0].Tags, []string{"alt"}) {
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", history[0].Tags)
}
}
@ -1050,20 +1050,20 @@ func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) {
if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil {
t.Fatal(err)
}
if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 {
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie))
if history, _ := s.EntryVersions(ctx, e.ID); len(history) != 1 {
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(history))
}
if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 {
t.Fatalf("PurgeFromTrash: %d, %v", n, err)
}
historie, err := s.EntryVersions(ctx, e.ID)
history, err := s.EntryVersions(ctx, e.ID)
if err != nil {
t.Fatal(err)
}
if len(historie) != 0 {
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie))
if len(history) != 0 {
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(history))
}
}
@ -1082,33 +1082,33 @@ func TestLinkGraph(t *testing.T) {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
Content: "allein", Source: SourceHuman})
knoten, kanten, err := s.LinkGraph(ctx, p.ID)
nodes, edges, err := s.LinkGraph(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
if len(knoten) != 4 {
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten))
if len(nodes) != 4 {
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(nodes))
}
nachSlug := make(map[string]GraphNode, len(knoten))
for _, k := range knoten {
nachSlug[k.Slug] = k
toSlug := make(map[string]GraphNode, len(nodes))
for _, k := range nodes {
toSlug[k.Slug] = k
}
if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists {
if !toSlug["a"].Exists || !toSlug["b"].Exists || !toSlug["c"].Exists {
t.Error("vorhandene Einträge sollten Exists = true tragen")
}
fehlt, ok := nachSlug["fehlt"]
missing, ok := toSlug["fehlt"]
if !ok {
t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten")
}
if fehlt.Exists {
if missing.Exists {
t.Error("Exists = true für einen Bezeichner ohne Eintrag")
}
if len(kanten) != 3 {
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten))
if len(edges) != 3 {
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(edges))
}
}
@ -1125,28 +1125,28 @@ func TestLinkGraphIsProjectScoped(t *testing.T) {
s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes",
Content: "nur in B", Source: SourceHuman})
knoten, _, err := s.LinkGraph(ctx, a.ID)
nodes, _, err := s.LinkGraph(ctx, a.ID)
if err != nil {
t.Fatal(err)
}
if len(knoten) != 1 || knoten[0].Slug != "geheim" {
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten)
if len(nodes) != 1 || nodes[0].Slug != "geheim" {
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", nodes)
}
}
// TestVeralteteEintraegeFilternUndZaehlen: ListEntries mit ChangedBefore und
// TestStaleEntriesFilterAndCount: ListEntries mit ChangedBefore und
// CountStaleEntries finden genau die Einträge, die seit über einem Jahr
// unverändert sind — und keine im Papierkorb.
func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) {
func TestStaleEntriesFilterAndCount(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
jetzt := time.Now()
now := time.Now()
alt := jetzt.Add(-400 * 24 * time.Hour)
old := now.Add(-400 * 24 * time.Hour)
for _, w := range []EntryWrite{
{Slug: "alt", Title: "Alt", CreatedAt: alt, UpdatedAt: alt},
{Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: alt, UpdatedAt: alt},
{Slug: "alt", Title: "Alt", CreatedAt: old, UpdatedAt: old},
{Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: old, UpdatedAt: old},
{Slug: "frisch", Title: "Frisch"},
} {
w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman
@ -1158,17 +1158,17 @@ func TestVeralteteEintraegeFilternUndZaehlen(t *testing.T) {
t.Fatal(err)
}
liste, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(jetzt)})
list, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(now)})
if err != nil {
t.Fatal(err)
}
if len(liste) != 1 || liste[0].Slug != "alt" {
t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(liste))
if len(list) != 1 || list[0].Slug != "alt" {
t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(list))
}
anzahl, err := s.CountStaleEntries(ctx, p.ID, jetzt)
if err != nil || anzahl != 1 {
t.Errorf("CountStaleEntries = %d, %v; erwartet 1", anzahl, err)
count, err := s.CountStaleEntries(ctx, p.ID, now)
if err != nil || count != 1 {
t.Errorf("CountStaleEntries = %d, %v; erwartet 1", count, err)
}
}
@ -1179,86 +1179,86 @@ func TestAuditFilter(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
jetzt := time.Now().UTC()
now := time.Now().UTC()
for _, z := range []struct {
alter time.Duration
wer, vorgang string
age time.Duration
who, event string
}{
{48 * time.Hour, "anna", ActionEntryWrite},
{24 * time.Hour, "bernd", ActionEntryDelete},
{1 * time.Hour, "anna", ActionEntryDelete},
} {
if _, err := s.schreiben.ExecContext(ctx,
if _, err := s.write.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`,
NewID(), jetzt.Add(-z.alter).Format(time.RFC3339Nano), p.ID, z.wer, z.vorgang); err != nil {
NewID(), now.Add(-z.age).Format(time.RFC3339Nano), p.ID, z.who, z.event); err != nil {
t.Fatal(err)
}
}
zaehle := func(f AuditFilter) int {
count := func(f AuditFilter) int {
t.Helper()
f.ProjectID = p.ID
liste, err := s.ListAudit(ctx, f)
list, err := s.ListAudit(ctx, f)
if err != nil {
t.Fatal(err)
}
return len(liste)
return len(list)
}
if n := zaehle(AuditFilter{Action: ActionEntryDelete}); n != 2 {
if n := count(AuditFilter{Action: ActionEntryDelete}); n != 2 {
t.Errorf("Vorgang: %d, erwartet 2", n)
}
if n := zaehle(AuditFilter{Actor: "anna"}); n != 2 {
if n := count(AuditFilter{Actor: "anna"}); n != 2 {
t.Errorf("Zugang: %d, erwartet 2", n)
}
if n := zaehle(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 {
if n := count(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 {
t.Errorf("Zugang und Vorgang: %d, erwartet 1", n)
}
if n := zaehle(AuditFilter{Since: jetzt.Add(-30 * time.Hour), Until: jetzt.Add(-2 * time.Hour)}); n != 1 {
if n := count(AuditFilter{Since: now.Add(-30 * time.Hour), Until: now.Add(-2 * time.Hour)}); n != 1 {
t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n)
}
zugaenge, err := s.AuditActors(ctx, p.ID)
if err != nil || strings.Join(zugaenge, ",") != "anna,bernd" {
t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", zugaenge, err)
keys, err := s.AuditActors(ctx, p.ID)
if err != nil || strings.Join(keys, ",") != "anna,bernd" {
t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", keys, err)
}
}
// TestKeyAblauf: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt
// TestKeyExpiry: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt
// sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht
// erst angenommen.
func TestKeyAblauf(t *testing.T) {
func TestKeyExpiry(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
jetzt := time.Now()
now := time.Now()
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", jetzt.Add(-time.Hour)); err == nil {
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", now.Add(-time.Hour)); err == nil {
t.Error("Key mit Ablauf in der Vergangenheit wurde angelegt")
}
k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", jetzt.Add(time.Hour))
k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", now.Add(time.Hour))
if err != nil {
t.Fatal(err)
}
if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil {
t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err)
}
gelesen, _ := s.APIKeyByID(ctx, k.ID)
if gelesen.ExpiresAt.IsZero() || !gelesen.ExpiresSoon(jetzt) || gelesen.Expired(jetzt) {
t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", gelesen)
read, _ := s.APIKeyByID(ctx, k.ID)
if read.ExpiresAt.IsZero() || !read.ExpiresSoon(now) || read.Expired(now) {
t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", read)
}
// Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist.
if _, err := s.schreiben.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`,
jetzt.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil {
if _, err := s.write.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`,
now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil {
t.Fatal(err)
}
if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) {
t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err)
}
gelesen, _ = s.APIKeyByID(ctx, k.ID)
if gelesen.Valid(jetzt) || gelesen.Unused(jetzt.Add(200*24*time.Hour)) {
read, _ = s.APIKeyByID(ctx, k.ID)
if read.Valid(now) || read.Unused(now.Add(200*24*time.Hour)) {
t.Error("abgelaufener Key gilt als gültig oder als ungenutzt")
}

View file

@ -4,10 +4,10 @@ package store
// (AUSBAUSTUFEN.md 5.16): Bestand, Platz auf der Platte, Aktivität je Tag und
// Stand des Schemas.
//
// Anders als die Nutzung (nutzung.go) fragt hier niemand nach einem einzelnen
// Anders als die Nutzung (usage.go) fragt hier niemand nach einem einzelnen
// Projekt. Die Zahlen gelten für die ganze Workspace-Datei bzw. das ganze
// Schema; zusammengezählt über alle Workspaces werden sie in
// internal/statistik.
// internal/stats.
import (
"context"
@ -17,57 +17,57 @@ import (
"time"
)
// Bestand zählt, was in einem Workspace liegt.
type Bestand struct {
Projekte int
Eintraege int // ohne Papierkorb
Fassungen int // ältere Fassungen in entry_versions
Papierkorb int
// Inventory zählt, was in einem Workspace liegt.
type Inventory struct {
Projects int
Entries int // ohne Papierkorb
Versions int // ältere Fassungen in entry_versions
Trash int
// Keys: nur gültige (weder zurückgezogen noch abgelaufen). Ungenutzt und
// LaufenAb sind Teilmengen davon, nach denselben Regeln wie auf der Seite
// "Zugänge" (APIKey.Unused, APIKey.ExpiresSoon).
Keys int
KeysUngenutzt int
KeysLaufenAb int
Keys int
KeysUnused int
KeysExpiring int
}
// Plus zählt zwei Bestände zusammen — für die Summenzeile über alle
// Workspaces.
func (b Bestand) Plus(o Bestand) Bestand {
return Bestand{
Projekte: b.Projekte + o.Projekte,
Eintraege: b.Eintraege + o.Eintraege,
Fassungen: b.Fassungen + o.Fassungen,
Papierkorb: b.Papierkorb + o.Papierkorb,
Keys: b.Keys + o.Keys,
KeysUngenutzt: b.KeysUngenutzt + o.KeysUngenutzt,
KeysLaufenAb: b.KeysLaufenAb + o.KeysLaufenAb,
func (b Inventory) Plus(o Inventory) Inventory {
return Inventory{
Projects: b.Projects + o.Projects,
Entries: b.Entries + o.Entries,
Versions: b.Versions + o.Versions,
Trash: b.Trash + o.Trash,
Keys: b.Keys + o.Keys,
KeysUnused: b.KeysUnused + o.KeysUnused,
KeysExpiring: b.KeysExpiring + o.KeysExpiring,
}
}
// Bestand zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des
// Workspace. jetzt entscheidet, welche Keys als ungenutzt oder bald
// Inventory zählt Projekte, Einträge, Fassungen, Papierkorb und Keys des
// Workspace. now entscheidet, welche Keys als ungenutzt oder bald
// ablaufend gelten.
func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) {
var b Bestand
func (s *Store) Inventory(ctx context.Context, now time.Time) (Inventory, error) {
var b Inventory
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projekte); err != nil {
if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&b.Projects); err != nil {
return b, fmt.Errorf("Projekte konnten nicht gezählt werden: %w", err)
}
// Eine Abfrage für beide Zahlen. COALESCE, weil SUM über eine leere
// Tabelle NULL ergibt, nicht 0.
err := s.lesen.QueryRowContext(ctx,
err := s.read.QueryRowContext(ctx,
`SELECT COALESCE(SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END), 0)
FROM entries`,
).Scan(&b.Eintraege, &b.Papierkorb)
).Scan(&b.Entries, &b.Trash)
if err != nil {
return b, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
}
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Fassungen); err != nil {
if err := s.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_versions`).Scan(&b.Versions); err != nil {
return b, fmt.Errorf("Fassungen konnten nicht gezählt werden: %w", err)
}
@ -75,7 +75,7 @@ func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) {
// bald ab" heißt, steht in models.go — einmal, für die Seite "Zugänge" und
// für diese hier. Eine zweite Fassung der Regel in SQL könnte unbemerkt
// davon abweichen. Zurückgezogene Keys fallen schon in der Abfrage weg.
rows, err := s.lesen.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE revoked_at = ''`)
rows, err := s.read.QueryContext(ctx, `SELECT `+keyColumns+` FROM api_keys WHERE revoked_at = ''`)
if err != nil {
return b, fmt.Errorf("Keys konnten nicht gelesen werden: %w", err)
}
@ -85,25 +85,25 @@ func (s *Store) Bestand(ctx context.Context, jetzt time.Time) (Bestand, error) {
if err != nil {
return b, err
}
if !k.Valid(jetzt) {
if !k.Valid(now) {
continue
}
b.Keys++
if k.Unused(jetzt) {
b.KeysUngenutzt++
if k.Unused(now) {
b.KeysUnused++
}
if k.ExpiresSoon(jetzt) {
b.KeysLaufenAb++
if k.ExpiresSoon(now) {
b.KeysExpiring++
}
}
return b, rows.Err()
}
// Belegung ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes.
type Belegung struct {
// Daten: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des
// DiskUsage ist der Platz, den ein Workspace in der Datenbank belegt, in Bytes.
type DiskUsage struct {
// Data: bei SQLite die Datei selbst, bei PostgreSQL alle Tabellen des
// Schemas samt Indizes.
Daten int64
Data int64
// WAL ist nur bei SQLite gesetzt: die Datei <id>.db-wal. In ihr sammelt
// SQLite Änderungen, bevor es sie in die Datenbank zurückschreibt
@ -113,80 +113,80 @@ type Belegung struct {
WAL int64
}
// Gesamt ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte.
func (b Belegung) Gesamt() int64 { return b.Daten + b.WAL }
// Total ist Daten und WAL zusammen — so viel liegt wirklich auf der Platte.
func (b DiskUsage) Total() int64 { return b.Data + b.WAL }
// Belegung misst den Platz des Workspace. Wie, hängt von der Datenbank ab
// (siehe dialekt.belegung).
func (s *Store) Belegung(ctx context.Context) (Belegung, error) {
return s.lesen.d.belegung(ctx, s.lesen)
// DiskUsage misst den Platz des Workspace. Wie, hängt von der Datenbank ab
// (siehe dialect.diskUsage).
func (s *Store) DiskUsage(ctx context.Context) (DiskUsage, error) {
return s.read.d.diskUsage(ctx, s.read)
}
// belegung (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei,
// diskUsage (SQLite): Seitenzahl mal Seitengröße ist die Größe der Datei,
// ohne sie im Dateisystem suchen zu müssen. Die WAL-Datei kennt SQLite selbst
// nicht als Zahl; ihr Pfad ergibt sich aber aus dem der Datenbank, und den
// nennt PRAGMA database_list.
func (sqliteDialekt) belegung(ctx context.Context, p pool) (Belegung, error) {
var b Belegung
var seiten, seitengroesse int64
if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&seiten); err != nil {
func (sqliteDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b DiskUsage
var pages, pageSize int64
if err := p.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pages); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&seitengroesse); err != nil {
if err := p.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err != nil {
return b, fmt.Errorf("Größe der Datenbank unbekannt: %w", err)
}
b.Daten = seiten * seitengroesse
b.Data = pages * pageSize
// database_list liefert je angehängter Datenbank eine Zeile: Nummer, Name
// ("main" ist die eigene) und Pfad.
var nummer int
var name, pfad string
if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&nummer, &name, &pfad); err != nil {
var number int
var name, filePath string
if err := p.QueryRowContext(ctx, `PRAGMA database_list`).Scan(&number, &name, &filePath); err != nil {
return b, fmt.Errorf("Pfad der Datenbank unbekannt: %w", err)
}
// Ohne WAL-Datei (gerade alles zurückgeschrieben) bleibt WAL 0 — das ist
// kein Fehler.
if info, err := os.Stat(pfad + "-wal"); err == nil {
if info, err := os.Stat(filePath + "-wal"); err == nil {
b.WAL = info.Size()
}
return b, nil
}
// belegung (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt.
// diskUsage (PostgreSQL): die Tabellen des eigenen Schemas zusammengezählt.
// pg_total_relation_size rechnet Indizes und ausgelagerte große Werte (TOAST)
// mit ein; deshalb nur über die Tabellen (relkind 'r') summieren, sonst
// zählten die Indizes doppelt.
//
// current_schema() ist das Schema des Workspace, weil jeder Pool seinen
// search_path darauf setzt (speicher_postgres.go). Die Summe ist numeric und
// search_path darauf setzt (backend_postgres.go). Die Summe ist numeric und
// wird nach bigint gewandelt, damit sie sich als int64 lesen lässt.
func (postgresDialekt) belegung(ctx context.Context, p pool) (Belegung, error) {
var b Belegung
func (postgresDialect) diskUsage(ctx context.Context, p pool) (DiskUsage, error) {
var b DiskUsage
err := p.QueryRowContext(ctx,
`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)::bigint
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relkind = 'r'`,
).Scan(&b.Daten)
).Scan(&b.Data)
if err != nil {
return b, fmt.Errorf("Größe des Schemas unbekannt: %w", err)
}
return b, nil
}
// Tagesaktivitaet zählt, was an einem Tag in einem Projekt geschah.
type Tagesaktivitaet struct {
ProjektID string
// DailyActivity zählt, was an einem Tag in einem Projekt geschah.
type DailyActivity struct {
ProjectID string
Tag string // "2026-10-16", in UTC — so steht es im Zeitstempel
SchreibenAgent int // über MCP angelegt, geändert oder gelöscht
SchreibenMensch int // dasselbe über die Weboberfläche
Suchen int // recall-Aufrufe aus dem Suchprotokoll
AgentWrites int // über MCP angelegt, geändert oder gelöscht
HumanWrites int // dasselbe über die Weboberfläche
Searches int // recall-Aufrufe aus dem Suchprotokoll
}
// Aktivitaet liefert je Projekt und Tag die Schreibvorgänge und Suchen seit
// seit. Tage und Projekte ohne jede Aktivität fehlen.
// Activity liefert je Projekt und Tag die Schreibvorgänge und Suchen seit
// since. Tage und Projekte ohne jede Aktivität fehlen.
//
// Geschrieben heißt hier dasselbe wie in der Nutzung (nutzung.go): angelegt,
// Geschrieben heißt hier dasselbe wie in der Nutzung (usage.go): angelegt,
// geändert, in den Papierkorb gelegt. Gelesen wird nur gezählt, was im
// Suchprotokoll steht, also recall. Ein get taucht nirgends auf — es gibt
// dafür kein Protokoll, und eines nur für diese Zahl wäre zu viel.
@ -194,43 +194,43 @@ type Tagesaktivitaet struct {
// Der Tag sind die ersten zehn Zeichen des Zeitstempels (RFC3339 in UTC).
// SUBSTR gibt es in beiden Datenbanken, und gruppiert wird nach genau
// diesem Ausdruck, nicht nach einer Spaltennummer.
func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivitaet, error) {
grenze := seit.UTC().Format(time.RFC3339Nano)
func (s *Store) Activity(ctx context.Context, since time.Time) ([]DailyActivity, error) {
limit := since.UTC().Format(time.RFC3339Nano)
// Projekt und Tag zusammen sind der Schlüssel; beide Abfragen tragen in
// dieselbe map ein.
type schluessel struct{ projekt, tag string }
je := map[schluessel]*Tagesaktivitaet{}
eintrag := func(projekt, tag string) *Tagesaktivitaet {
k := schluessel{projekt, tag}
if je[k] == nil {
je[k] = &Tagesaktivitaet{ProjektID: projekt, Tag: tag}
type activityKey struct{ project, tag string }
byKey := map[activityKey]*DailyActivity{}
entry := func(project, tag string) *DailyActivity {
k := activityKey{project, tag}
if byKey[k] == nil {
byKey[k] = &DailyActivity{ProjectID: project, Tag: tag}
}
return je[k]
return byKey[k]
}
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), source, COUNT(*)
FROM audit_events
WHERE ts >= ? AND action IN (?, ?)
GROUP BY project_id, SUBSTR(ts, 1, 10), source`,
grenze, ActionEntryWrite, ActionEntryDelete,
limit, ActionEntryWrite, ActionEntryDelete,
)
if err != nil {
return nil, fmt.Errorf("Schreibvorgänge konnten nicht gezählt werden: %w", err)
}
for rows.Next() {
var projekt, tag, quelle string
var anzahl int
if err := rows.Scan(&projekt, &tag, &quelle, &anzahl); err != nil {
var project, tag, source string
var count int
if err := rows.Scan(&project, &tag, &source, &count); err != nil {
rows.Close()
return nil, err
}
switch quelle {
switch source {
case SourceMCP:
eintrag(projekt, tag).SchreibenAgent += anzahl
entry(project, tag).AgentWrites += count
case SourceWeb:
eintrag(projekt, tag).SchreibenMensch += anzahl
entry(project, tag).HumanWrites += count
}
}
if err := rows.Err(); err != nil {
@ -239,55 +239,55 @@ func (s *Store) Aktivitaet(ctx context.Context, seit time.Time) ([]Tagesaktivita
}
rows.Close()
rows, err = s.lesen.QueryContext(ctx,
rows, err = s.read.QueryContext(ctx,
`SELECT project_id, SUBSTR(ts, 1, 10), COUNT(*)
FROM search_log
WHERE ts >= ?
GROUP BY project_id, SUBSTR(ts, 1, 10)`,
grenze,
limit,
)
if err != nil {
return nil, fmt.Errorf("Suchen konnten nicht gezählt werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var projekt, tag string
var anzahl int
if err := rows.Scan(&projekt, &tag, &anzahl); err != nil {
var project, tag string
var count int
if err := rows.Scan(&project, &tag, &count); err != nil {
return nil, err
}
eintrag(projekt, tag).Suchen += anzahl
entry(project, tag).Searches += count
}
if err := rows.Err(); err != nil {
return nil, err
}
liste := make([]Tagesaktivitaet, 0, len(je))
for _, a := range je {
liste = append(liste, *a)
list := make([]DailyActivity, 0, len(byKey))
for _, a := range byKey {
list = append(list, *a)
}
return liste, nil
return list, nil
}
// Schemastand nennt die zuletzt angewandte Migration des Workspace (ist) und
// die neueste, die dieses Programm mitbringt (soll), z.B. "007_key_ablauf.sql".
// SchemaVersion nennt die zuletzt angewandte Migration des Workspace (actual) und
// die neueste, die dieses Programm mitbringt (expected), z.B. "007_key_expiry.sql".
//
// Beim Öffnen eines Workspace laufen fehlende Migrationen ohnehin durch
// (runMigrations). Weichen beide trotzdem voneinander ab, hat eine neuere
// Fassung von Munin die Datenbank schon weitergebracht als dieses Programm —
// etwa nach dem Zurückrollen auf eine ältere Fassung.
func (s *Store) Schemastand(ctx context.Context) (ist, soll string, err error) {
func (s *Store) SchemaVersion(ctx context.Context) (actual, expected string, err error) {
var name sql.NullString
if err := s.lesen.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil {
if err := s.read.QueryRowContext(ctx, `SELECT MAX(name) FROM schema_migrations`).Scan(&name); err != nil {
return "", "", fmt.Errorf("Migrationsstand konnte nicht gelesen werden: %w", err)
}
dateien, err := migrationFiles(migrationsFS, "migrations/"+s.lesen.d.migrationsUnterordner())
files, err := migrationFiles(migrationsFS, "migrations/"+s.read.d.migrationsSubdir())
if err != nil {
return "", "", err
}
if len(dateien) > 0 {
soll = dateien[len(dateien)-1]
if len(files) > 0 {
expected = files[len(files)-1]
}
return name.String, soll, nil
return name.String, expected, nil
}

View file

@ -6,22 +6,22 @@ import (
"time"
)
// Tests der Kennzahlen für die Systemseite (systemstand.go). Laufen mit
// Tests der Kennzahlen für die Systemseite (sysinfo.go). Laufen mit
// MUNIN_TEST_PG auch gegen PostgreSQL (siehe testStore).
// TestBestand zählt Projekte, Einträge, Papierkorb, Fassungen und Keys —
// TestInventory zählt Projekte, Einträge, Papierkorb, Fassungen und Keys —
// Keys nach denselben Regeln wie die Seite "Zugänge".
func TestBestand(t *testing.T) {
func TestInventory(t *testing.T) {
s := testStore(t)
ctx := context.Background()
jetzt := time.Now()
now := time.Now()
leer, err := s.Bestand(ctx, jetzt)
empty, err := s.Inventory(ctx, now)
if err != nil {
t.Fatal(err)
}
if leer != (Bestand{}) {
t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", leer)
if empty != (Inventory{}) {
t.Errorf("leerer Workspace: %+v, erwartet nur Nullen", empty)
}
p := testProject(t, s)
@ -43,99 +43,99 @@ func TestBestand(t *testing.T) {
if _, err := s.CreateAPIKey(ctx, p.ID, "frisch", "h1", "write"); err != nil {
t.Fatal(err)
}
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "bald", "h2", "read", jetzt.Add(3*24*time.Hour)); err != nil {
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "bald", "h2", "read", now.Add(3*24*time.Hour)); err != nil {
t.Fatal(err)
}
weg, err := s.CreateAPIKey(ctx, p.ID, "weg", "h3", "read")
gone, err := s.CreateAPIKey(ctx, p.ID, "weg", "h3", "read")
if err != nil {
t.Fatal(err)
}
if err := s.RevokeAPIKey(ctx, weg.ID); err != nil {
if err := s.RevokeAPIKey(ctx, gone.ID); err != nil {
t.Fatal(err)
}
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "abgelaufen", "h4", "read", jetzt.Add(time.Hour)); err != nil {
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "abgelaufen", "h4", "read", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
// Zwei Stunden später: "abgelaufen" ist es dann.
b, err := s.Bestand(ctx, jetzt.Add(2*time.Hour))
b, err := s.Inventory(ctx, now.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
erwartet := Bestand{Projekte: 1, Eintraege: 2, Fassungen: 1, Papierkorb: 1, Keys: 2, KeysLaufenAb: 1}
if b != erwartet {
t.Errorf("Bestand = %+v\nerwartet %+v", b, erwartet)
want := Inventory{Projects: 1, Entries: 2, Versions: 1, Trash: 1, Keys: 2, KeysExpiring: 1}
if b != want {
t.Errorf("Bestand = %+v\nerwartet %+v", b, want)
}
// 100 Tage später ist "frisch" nie benutzt worden und damit ungenutzt;
// "bald" ist bis dahin abgelaufen und zählt gar nicht mehr.
b, err = s.Bestand(ctx, jetzt.Add(100*24*time.Hour))
b, err = s.Inventory(ctx, now.Add(100*24*time.Hour))
if err != nil {
t.Fatal(err)
}
if b.Keys != 1 || b.KeysUngenutzt != 1 || b.KeysLaufenAb != 0 {
t.Errorf("nach 100 Tagen: Keys %d, ungenutzt %d, laufen ab %d — erwartet 1, 1, 0", b.Keys, b.KeysUngenutzt, b.KeysLaufenAb)
if b.Keys != 1 || b.KeysUnused != 1 || b.KeysExpiring != 0 {
t.Errorf("nach 100 Tagen: Keys %d, ungenutzt %d, laufen ab %d — erwartet 1, 1, 0", b.Keys, b.KeysUnused, b.KeysExpiring)
}
}
// TestBelegung: Ein Workspace mit Inhalt belegt Platz. Wie viel genau, hängt
// TestDiskUsage: Ein Workspace mit Inhalt belegt Platz. Wie viel genau, hängt
// von der Datenbank ab — geprüft wird nur, dass die Zahl plausibel ist und
// mit dem Inhalt wächst.
func TestBelegung(t *testing.T) {
func TestDiskUsage(t *testing.T) {
s := testStore(t)
ctx := context.Background()
vorher, err := s.Belegung(ctx)
before, err := s.DiskUsage(ctx)
if err != nil {
t.Fatal(err)
}
if vorher.Daten <= 0 {
t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", vorher.Daten)
if before.Data <= 0 {
t.Fatalf("leerer Workspace belegt %d Bytes — die Tabellen allein brauchen Platz", before.Data)
}
p := testProject(t, s)
inhalt := make([]byte, 64<<10)
for i := range inhalt {
inhalt[i] = 'a' + byte(i%26)
content := make([]byte, 64<<10)
for i := range content {
content[i] = 'a' + byte(i%26)
}
for _, slug := range []string{"gross-1", "gross-2", "gross-3", "gross-4"} {
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: string(inhalt), Source: SourceHuman}); err != nil {
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Content: string(content), Source: SourceHuman}); err != nil {
t.Fatal(err)
}
}
nachher, err := s.Belegung(ctx)
after, err := s.DiskUsage(ctx)
if err != nil {
t.Fatal(err)
}
// Bei SQLite landet das Geschriebene zunächst in der WAL-Datei, bei
// PostgreSQL in den Tabellen. Zusammen muss es mehr geworden sein.
if nachher.Gesamt() <= vorher.Gesamt() {
t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", vorher.Gesamt(), nachher.Gesamt())
if after.Total() <= before.Total() {
t.Errorf("Belegung vorher %d, nachher %d — sollte gewachsen sein", before.Total(), after.Total())
}
}
// TestAktivitaet zählt Schreibvorgänge je Projekt, Tag und Weg sowie Suchen,
// TestActivity zählt Schreibvorgänge je Projekt, Tag und Weg sowie Suchen,
// und lässt alles vor der Grenze weg.
func TestAktivitaet(t *testing.T) {
func TestActivity(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
// Mit festen Zeitstempeln direkt eintragen — LogAudit und LogSearch
// nehmen immer "jetzt".
audit := func(ts, aktion, quelle string) {
audit := func(ts, action, source string) {
t.Helper()
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, actor, action, source) VALUES (?, ?, ?, 'test', ?, ?)`,
NewID(), ts, p.ID, aktion, quelle)
NewID(), ts, p.ID, action, source)
if err != nil {
t.Fatal(err)
}
}
suche := func(ts string) {
search := func(ts string) {
t.Helper()
_, err := s.schreiben.ExecContext(ctx,
_, err := s.write.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'frage', 1)`, ts, p.ID)
if err != nil {
t.Fatal(err)
@ -148,42 +148,42 @@ func TestAktivitaet(t *testing.T) {
audit("2026-10-01T11:00:00Z", ActionLoginSuccess, SourceWeb) // kein Schreibvorgang
audit("2026-10-02T08:00:00Z", ActionEntryWrite, SourceWeb)
audit("2026-09-20T08:00:00Z", ActionEntryWrite, SourceMCP) // vor der Grenze
suche("2026-10-02T12:00:00Z")
suche("2026-10-02T13:00:00Z")
suche("2026-09-20T13:00:00Z") // vor der Grenze
search("2026-10-02T12:00:00Z")
search("2026-10-02T13:00:00Z")
search("2026-09-20T13:00:00Z") // vor der Grenze
liste, err := s.Aktivitaet(ctx, time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC))
list, err := s.Activity(ctx, time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
je := map[string]Tagesaktivitaet{}
for _, a := range liste {
if a.ProjektID != p.ID {
t.Errorf("fremdes Projekt %q", a.ProjektID)
per := map[string]DailyActivity{}
for _, a := range list {
if a.ProjectID != p.ID {
t.Errorf("fremdes Projekt %q", a.ProjectID)
}
je[a.Tag] = a
per[a.Tag] = a
}
if len(je) != 2 {
t.Fatalf("%d Tage, erwartet 2: %+v", len(je), liste)
if len(per) != 2 {
t.Fatalf("%d Tage, erwartet 2: %+v", len(per), list)
}
if a := je["2026-10-01"]; a.SchreibenAgent != 2 || a.SchreibenMensch != 1 || a.Suchen != 0 {
if a := per["2026-10-01"]; a.AgentWrites != 2 || a.HumanWrites != 1 || a.Searches != 0 {
t.Errorf("1.10.: %+v, erwartet 2 Agent, 1 Mensch, 0 Suchen", a)
}
if a := je["2026-10-02"]; a.SchreibenAgent != 0 || a.SchreibenMensch != 1 || a.Suchen != 2 {
if a := per["2026-10-02"]; a.AgentWrites != 0 || a.HumanWrites != 1 || a.Searches != 2 {
t.Errorf("2.10.: %+v, erwartet 0 Agent, 1 Mensch, 2 Suchen", a)
}
}
// TestSchemastand: Nach dem Öffnen steht der Workspace auf der neuesten
// TestSchemaVersion: Nach dem Öffnen steht der Workspace auf der neuesten
// Migration, die das Programm mitbringt.
func TestSchemastand(t *testing.T) {
func TestSchemaVersion(t *testing.T) {
s := testStore(t)
ist, soll, err := s.Schemastand(context.Background())
actual, expected, err := s.SchemaVersion(context.Background())
if err != nil {
t.Fatal(err)
}
if soll == "" || ist != soll {
t.Errorf("Schemastand ist %q, soll %q", ist, soll)
if expected == "" || actual != expected {
t.Errorf("Schemastand ist %q, soll %q", actual, expected)
}
}

View file

@ -1,6 +1,6 @@
package store
// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_papierkorb.sql).
// Papierkorb (AUSBAUSTUFEN.md 3.9, migrations/005_trash.sql).
//
// Der Lebenslauf eines gelöschten Eintrags:
//
@ -41,16 +41,16 @@ const TrashRetentionDays = int(TrashRetention / (24 * time.Hour))
//
// Inhalt, Schlagworte, Verweise und Verlauf bleiben unverändert stehen, auch
// die Fassungsnummer: Löschen ist keine neue Fassung, sondern eine Markierung
// daneben. geloeschtVon ist der Name des Zugangs, der gelöscht hat — die
// daneben. deletedBy ist der Name des Zugangs, der gelöscht hat — die
// Papierkorb-Seite zeigt ihn, damit man sieht, ob ein Agent aufgeräumt hat.
//
// Liegt der Eintrag schon im Papierkorb (oder gibt es ihn nicht), kommt
// ErrNotFound — aus Sicht des Aufrufers gibt es ihn ja nicht mehr.
func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloeschtVon string) error {
res, err := s.schreiben.ExecContext(ctx,
func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, deletedBy string) error {
res, err := s.write.ExecContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb,
NowString(), geloeschtVon, projectID, slug,
WHERE project_id = ? AND slug = ? AND `+notInTrash,
NowString(), deletedBy, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
@ -71,20 +71,20 @@ func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloescht
// Alles läuft in einer Transaktion: Entweder liegen danach alle gültigen
// Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der
// tatsächlich gelöschten Einträge — für Suchindex und Protokoll.
func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, geloeschtVon string) ([]string, error) {
var geloescht []string
jetzt := NowString()
func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, deletedBy string) ([]string, error) {
var deleted []string
now := NowString()
err := s.tx(ctx, func(tx *transaktion) error {
err := s.tx(ctx, func(tx *transaction) error {
for _, id := range entryIDs {
// RETURNING liefert die Spalte der geänderten Zeile gleich mit —
// so braucht es keine zweite Abfrage, um den Bezeichner zu holen.
var slug string
err := tx.QueryRowContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb+`
WHERE id = ? AND project_id = ? AND `+notInTrash+`
RETURNING slug`,
jetzt, geloeschtVon, id, projectID,
now, deletedBy, id, projectID,
).Scan(&slug)
if errors.Is(err, sql.ErrNoRows) {
continue // fremdes Projekt, schon im Papierkorb oder gibt es nicht
@ -92,49 +92,49 @@ func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryI
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
geloescht = append(geloescht, slug)
deleted = append(deleted, slug)
}
return nil
})
if err != nil {
return nil, err
}
return geloescht, nil
return deleted, nil
}
// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst.
//
// geloeschtVon schränkt auf einen Zugang ein (leer = alle). So lässt sich
// deletedBy schränkt auf einen Zugang ein (leer = alle). So lässt sich
// alles, was ein bestimmter Agent gelöscht hat, auf einen Blick sehen und in
// einem Zug zurückholen.
func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) ([]TrashedEntry, error) {
abfrage := `SELECT id, slug, title, version, deleted_at, deleted_by
func (s *Store) ListTrash(ctx context.Context, projectID, deletedBy string) ([]TrashedEntry, error) {
query := `SELECT id, slug, title, version, deleted_at, deleted_by
FROM entries
WHERE project_id = ? AND deleted_at IS NOT NULL`
werte := []any{projectID}
if geloeschtVon != "" {
abfrage += ` AND deleted_by = ?`
werte = append(werte, geloeschtVon)
values := []any{projectID}
if deletedBy != "" {
query += ` AND deleted_by = ?`
values = append(values, deletedBy)
}
abfrage += ` ORDER BY deleted_at DESC`
query += ` ORDER BY deleted_at DESC`
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []TrashedEntry
var list []TrashedEntry
for rows.Next() {
var t TrashedEntry
var geloeschtAm string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &geloeschtAm, &t.DeletedBy); err != nil {
var deletedAt string
if err := rows.Scan(&t.ID, &t.Slug, &t.Title, &t.Version, &deletedAt, &t.DeletedBy); err != nil {
return nil, err
}
t.DeletedAt = parseTime(geloeschtAm)
liste = append(liste, t)
t.DeletedAt = parseTime(deletedAt)
list = append(list, t)
}
return liste, rows.Err()
return list, rows.Err()
}
// RestoreFromTrash holt Einträge aus dem Papierkorb zurück.
@ -146,9 +146,9 @@ func (s *Store) ListTrash(ctx context.Context, projectID, geloeschtVon string) (
// Zurückgegeben werden die wiederhergestellten Einträge samt Schlagworten,
// damit der Aufrufer sie wieder in den Suchindex aufnehmen kann.
func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []string) ([]Entry, error) {
var zurueck []Entry
var back []Entry
err := s.tx(ctx, func(tx *transaktion) error {
err := s.tx(ctx, func(tx *transaction) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`UPDATE entries SET deleted_at = NULL, deleted_by = ''
@ -168,7 +168,7 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []
if err != nil {
return fmt.Errorf("Eintrag %q konnte nicht neu gelesen werden: %w", slug, err)
}
zurueck = append(zurueck, e)
back = append(back, e)
}
return nil
})
@ -178,7 +178,7 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []
// Die Schlagworte erst nach der Transaktion nachladen: attachTags liest
// über den Lese-Pool, und der sieht die Änderung erst nach dem Commit.
return zurueck, s.attachTagsToSlice(ctx, zurueck)
return back, s.attachTagsToSlice(ctx, back)
}
// PurgeFromTrash löscht Einträge aus dem Papierkorb endgültig, samt Verlauf,
@ -189,8 +189,8 @@ func (s *Store) RestoreFromTrash(ctx context.Context, projectID string, slugs []
// Eintrag, der nicht im Papierkorb liegt, wird nie angefasst — auch nicht,
// wenn jemand seinen Bezeichner ins Formular schmuggelt.
func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []string) (int, error) {
geloescht := 0
err := s.tx(ctx, func(tx *transaktion) error {
deleted := 0
err := s.tx(ctx, func(tx *transaction) error {
for _, slug := range slugs {
res, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ? AND deleted_at IS NOT NULL`,
@ -200,39 +200,39 @@ func (s *Store) PurgeFromTrash(ctx context.Context, projectID string, slugs []st
return fmt.Errorf("Eintrag %q konnte nicht endgültig gelöscht werden: %w", slug, err)
}
n, _ := res.RowsAffected()
geloescht += int(n)
deleted += int(n)
}
return nil
})
if err != nil {
return 0, err
}
return geloescht, nil
return deleted, nil
}
// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis aelterAls im
// PurgeTrash räumt alle Einträge endgültig ab, die länger als bis olderThan im
// Papierkorb liegen — über alle Projekte dieser Workspace-Datei. Gedacht für
// die tägliche Aufgabe in main.go, die auch das Protokoll aufräumt.
//
// Gibt die Bezeichner der entfernten Einträge je Projekt zurück, damit der
// Aufrufer jeden Fall im Protokoll vermerken kann.
func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]string, error) {
entfernt := map[string][]string{}
func (s *Store) PurgeTrash(ctx context.Context, olderThan string) (map[string][]string, error) {
removed := map[string][]string{}
err := s.tx(ctx, func(tx *transaktion) error {
err := s.tx(ctx, func(tx *transaction) error {
// Erst lesen, was gleich verschwindet — nach dem DELETE wäre es weg.
rows, err := tx.QueryContext(ctx,
`SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls)
`SELECT project_id, slug FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, olderThan)
if err != nil {
return fmt.Errorf("Papierkorb konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projektID, slug string
if err := rows.Scan(&projektID, &slug); err != nil {
var projectID, slug string
if err := rows.Scan(&projectID, &slug); err != nil {
rows.Close()
return err
}
entfernt[projektID] = append(entfernt[projektID], slug)
removed[projectID] = append(removed[projectID], slug)
}
if err := rows.Err(); err != nil {
rows.Close()
@ -241,7 +241,7 @@ func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]
rows.Close()
if _, err := tx.ExecContext(ctx,
`DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, aelterAls,
`DELETE FROM entries WHERE deleted_at IS NOT NULL AND deleted_at < ?`, olderThan,
); err != nil {
return fmt.Errorf("Papierkorb konnte nicht aufgeräumt werden: %w", err)
}
@ -250,5 +250,5 @@ func (s *Store) PurgeTrash(ctx context.Context, aelterAls string) (map[string][]
if err != nil {
return nil, err
}
return entfernt, nil
return removed, nil
}

View file

@ -7,22 +7,22 @@ import (
"time"
)
// TestPapierkorbUnsichtbar ist der wichtigste Test des Papierkorbs: Jeder
// TestTrashInvisible ist der wichtigste Test des Papierkorbs: Jeder
// lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine
// neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe
// nichtImPapierkorb in entries.go).
// notInTrash in entries.go).
//
// Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen
// das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg"
// gelöscht.
func TestPapierkorbUnsichtbar(t *testing.T) {
func TestTrashInvisible(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
lebt, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt",
alive, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt",
Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman})
weg, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg",
gone, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg",
Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true})
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil {
@ -43,11 +43,11 @@ func TestPapierkorbUnsichtbar(t *testing.T) {
"mit Schlagwort": {Tag: "nur-weg"},
"nur kuratierte": {Curated: true},
} {
liste, err := s.ListEntries(ctx, p.ID, filter)
list, err := s.ListEntries(ctx, p.ID, filter)
if err != nil {
t.Fatal(err)
}
for _, e := range liste {
for _, e := range list {
if e.Slug == "weg" {
t.Errorf("%s: gelöschter Eintrag in der Liste", name)
}
@ -56,9 +56,9 @@ func TestPapierkorbUnsichtbar(t *testing.T) {
})
t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) {
alle, _ := s.AllEntries(ctx, p.ID)
if len(alle) != 1 || alle[0].Slug != "lebt" {
t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle)
all, _ := s.AllEntries(ctx, p.ID)
if len(all) != 1 || all[0].Slug != "lebt" {
t.Errorf("AllEntries = %v, erwartet nur „lebt“", all)
}
})
@ -89,15 +89,15 @@ func TestPapierkorbUnsichtbar(t *testing.T) {
})
t.Run("LinkGraph", func(t *testing.T) {
knoten, kanten, _ := s.LinkGraph(ctx, p.ID)
for _, k := range knoten {
nodes, edges, _ := s.LinkGraph(ctx, p.ID)
for _, k := range nodes {
// "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber
// nur als gestrichelter "gibt es nicht".
if k.Slug == "weg" && k.Exists {
t.Error("gelöschter Eintrag erscheint im Graph als vorhanden")
}
}
for _, e := range kanten {
for _, e := range edges {
if e.From == "weg" {
t.Error("Verweis eines gelöschten Eintrags erscheint im Graph")
}
@ -105,9 +105,9 @@ func TestPapierkorbUnsichtbar(t *testing.T) {
})
t.Run("BulkUpdateTags", func(t *testing.T) {
geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil)
if len(geaendert) != 1 || geaendert[0].Slug != "lebt" {
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert)
changed, _ := s.BulkUpdateTags(ctx, p.ID, []string{alive.ID, gone.ID}, []string{"neu"}, nil)
if len(changed) != 1 || changed[0].Slug != "lebt" {
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", changed)
}
})
}
@ -123,14 +123,14 @@ func TestListTrash(t *testing.T) {
s.MoveEntryToTrash(ctx, p.ID, "a", "agent-1")
s.MoveEntryToTrash(ctx, p.ID, "b", "tom")
alle, _ := s.ListTrash(ctx, p.ID, "")
if len(alle) != 2 {
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle))
all, _ := s.ListTrash(ctx, p.ID, "")
if len(all) != 2 {
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(all))
}
vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1")
if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" {
t.Errorf("Filter nach Zugang: %+v", vomAgenten)
fromAgent, _ := s.ListTrash(ctx, p.ID, "agent-1")
if len(fromAgent) != 1 || fromAgent[0].Slug != "a" || fromAgent[0].DeletedBy != "agent-1" {
t.Errorf("Filter nach Zugang: %+v", fromAgent)
}
}
@ -138,31 +138,31 @@ func TestRestoreFromTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremd, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
foreign, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt",
Tags: []string{"x"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: fremd.ID, Slug: "f", Title: "F", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: foreign.ID, Slug: "f", Title: "F", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "tom")
s.MoveEntryToTrash(ctx, fremd.ID, "f", "tom")
s.MoveEntryToTrash(ctx, foreign.ID, "f", "tom")
// "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit.
zurueck, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"})
back, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"})
if err != nil {
t.Fatal(err)
}
if len(zurueck) != 1 || zurueck[0].Slug != "e" {
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck)
if len(back) != 1 || back[0].Slug != "e" {
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", back)
}
if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" {
t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags)
if len(back[0].Tags) != 1 || back[0].Tags[0] != "x" {
t.Errorf("Schlagworte nach Wiederherstellen = %v", back[0].Tags)
}
e, err := s.EntryBySlug(ctx, p.ID, "e")
if err != nil || e.Content != "Inhalt" || e.Version != 1 {
t.Errorf("nach Wiederherstellen: %+v, %v — Inhalt und Fassung müssen unverändert sein", e, err)
}
if liste, _ := s.ListTrash(ctx, fremd.ID, ""); len(liste) != 1 {
if list, _ := s.ListTrash(ctx, foreign.ID, ""); len(list) != 1 {
t.Error("der Eintrag des fremden Projekts wurde angefasst")
}
}
@ -178,31 +178,31 @@ func TestPutEntryRevivesFromTrash(t *testing.T) {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1")
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent})
newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent})
if err != nil {
t.Fatal(err)
}
if !neu.RestoredFromTrash {
if !newer.RestoredFromTrash {
t.Error("RestoredFromTrash nicht gesetzt")
}
if neu.Version != 2 {
t.Errorf("Fassung %d, erwartet 2", neu.Version)
if newer.Version != 2 {
t.Errorf("Fassung %d, erwartet 2", newer.Version)
}
if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil {
t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 0 {
if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 0 {
t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb")
}
alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1)
if err != nil || alt.Content != "gelöscht" {
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content)
old, err := s.EntryVersionByNumber(ctx, newer.ID, 1)
if err != nil || old.Content != "gelöscht" {
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, old.Content)
}
// Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb.
weiter, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
if weiter.RestoredFromTrash {
more, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
if more.RestoredFromTrash {
t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt")
}
}
@ -223,7 +223,7 @@ func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) {
if !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 {
if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 {
t.Error("der Eintrag wurde trotz Konflikt aus dem Papierkorb geholt")
}
}
@ -251,9 +251,9 @@ func TestPurgeFromTrashOnlyTrashed(t *testing.T) {
}
// Der Bezeichner ist danach wieder frei: Neu anlegen beginnt bei Fassung 1.
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err != nil || neu.Version != 1 || neu.RestoredFromTrash {
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err)
newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err != nil || newer.Version != 1 || newer.RestoredFromTrash {
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", newer, err)
}
}
@ -272,23 +272,23 @@ func TestPurgeTrashAfterRetention(t *testing.T) {
// "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht
// einen Monat warten.
vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil {
oneMonthAgo := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
if _, err := s.write.ExecContext(ctx,
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, oneMonthAgo); err != nil {
t.Fatal(err)
}
grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
entfernt, err := s.PurgeTrash(ctx, grenze)
limit := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
removed, err := s.PurgeTrash(ctx, limit)
if err != nil {
t.Fatal(err)
}
if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" {
t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt)
if len(removed[p.ID]) != 1 || removed[p.ID][0] != "alt" {
t.Errorf("entfernt: %v, erwartet nur „alt“", removed)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" {
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste)
if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 || list[0].Slug != "frisch" {
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", list)
}
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt")
@ -302,7 +302,7 @@ func TestMoveEntriesToTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremdesProjekt, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
foreignProject, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
if err != nil {
t.Fatal(err)
}
@ -310,34 +310,34 @@ func TestMoveEntriesToTrash(t *testing.T) {
a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "x", Source: SourceHuman})
b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "x", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: SourceHuman})
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: fremdesProjekt.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman})
foreign, _ := s.PutEntry(ctx, EntryWrite{ProjectID: foreignProject.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman})
geloescht, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, fremd.ID, "gibtesnicht"}, "mensch")
deleted, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, foreign.ID, "gibtesnicht"}, "mensch")
if err != nil {
t.Fatal(err)
}
if len(geloescht) != 2 || geloescht[0] != "a" || geloescht[1] != "b" {
t.Errorf("gelöscht = %v, erwartet [a b]", geloescht)
if len(deleted) != 2 || deleted[0] != "a" || deleted[1] != "b" {
t.Errorf("gelöscht = %v, erwartet [a b]", deleted)
}
if _, err := s.EntryBySlug(ctx, fremdesProjekt.ID, "f"); err != nil {
if _, err := s.EntryBySlug(ctx, foreignProject.ID, "f"); err != nil {
t.Errorf("der Eintrag eines fremden Projekts wurde angetastet: %v", err)
}
if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil {
t.Errorf("ein nicht ausgewählter Eintrag ist weg: %v", err)
}
papierkorb, err := s.ListTrash(ctx, p.ID, "")
trash, err := s.ListTrash(ctx, p.ID, "")
if err != nil {
t.Fatal(err)
}
if len(papierkorb) != 2 || papierkorb[0].DeletedBy != "mensch" {
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", papierkorb)
if len(trash) != 2 || trash[0].DeletedBy != "mensch" {
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", trash)
}
// Doppelt abgeschickt: nichts mehr zu tun, kein Fehler.
nochmal, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
if err != nil || len(nochmal) != 0 {
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", nochmal, err)
again, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
if err != nil || len(again) != 0 {
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", again, err)
}
}

View file

@ -77,7 +77,7 @@ func (u UsageSummary) Total() WriteCounts {
// CuratedPercent ist der Anteil kuratierter Einträge in Prozent (0–100).
func (u UsageSummary) CuratedPercent() int {
return prozent(u.Curated, u.Entries)
return percent(u.Curated, u.Entries)
}
// UpdatePercent ist der Anteil der Aktualisierungen an allen Schreibvorgängen
@ -88,19 +88,19 @@ func (u UsageSummary) UpdatePercent() int {
if t.Created+t.Updated == 0 {
return -1
}
return prozent(t.Updated, t.Created+t.Updated)
return percent(t.Updated, t.Created+t.Updated)
}
// prozent rechnet teil/ganz in ganze Prozent um, kaufmännisch gerundet.
// percent rechnet part/whole in ganze Prozent um, kaufmännisch gerundet.
// Ganzzahlig statt mit Kommazahlen: "37 %" reicht für diese Übersicht, und so
// gibt es keine Rundungsfragen in der Anzeige.
func prozent(teil, ganz int) int {
if ganz == 0 {
func percent(part, whole int) int {
if whole == 0 {
return 0
}
// Die Hälfte des Nenners dazuzählen, bevor ganzzahlig geteilt wird —
// das ist Runden statt Abschneiden.
return (teil*100 + ganz/2) / ganz
return (part*100 + whole/2) / whole
}
// UsageSummaries liefert die Kennzahlen je Projekt, als map von Projekt-ID
@ -111,9 +111,9 @@ func prozent(teil, ganz int) int {
// Projekte ganz ohne Einträge und ohne Schreibvorgänge fehlen in der map. Der
// Aufrufer bekommt für sie beim Nachschlagen den Nullwert — also überall 0,
// und genau das stimmt ja auch.
func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time.Time) (map[string]UsageSummary, error) {
ergebnis := map[string]UsageSummary{}
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
func (s *Store) UsageSummaries(ctx context.Context, projectID string, now time.Time) (map[string]UsageSummary, error) {
result := map[string]UsageSummary{}
since := now.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// ── 1. Schreibvorgänge aus dem Protokoll ────────────────────────────────
//
@ -122,48 +122,48 @@ func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time
// angelegt oder aktualisiert wurde (siehe DetailEntryCreated in audit.go).
// Alles, was nicht "angelegt" und nicht leer ist, ist eine Aktualisierung:
// "aktualisiert", "wiederhergestellt: …", "Sammelbearbeitung: …".
abfrage := `SELECT project_id, source,
query := `SELECT project_id, source,
SUM(CASE WHEN action = ? AND detail = ? THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? AND detail <> ? AND detail <> '' THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? AND detail = '' THEN 1 ELSE 0 END),
SUM(CASE WHEN action = ? THEN 1 ELSE 0 END)
FROM audit_events
WHERE ts >= ? AND action IN (?, ?)`
werte := []any{
values := []any{
ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite, DetailEntryCreated,
ActionEntryWrite,
ActionEntryDelete,
seit, ActionEntryWrite, ActionEntryDelete,
since, ActionEntryWrite, ActionEntryDelete,
}
if projectID != "" {
abfrage += ` AND project_id = ?`
werte = append(werte, projectID)
query += ` AND project_id = ?`
values = append(values, projectID)
}
abfrage += ` GROUP BY project_id, source`
query += ` GROUP BY project_id, source`
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err := s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Nutzung konnte nicht gelesen werden: %w", err)
}
for rows.Next() {
var projekt, quelle string
var project, source string
var z WriteCounts
if err := rows.Scan(&projekt, &quelle, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil {
if err := rows.Scan(&project, &source, &z.Created, &z.Updated, &z.Unknown, &z.Deleted); err != nil {
rows.Close()
return nil, err
}
// Werte aus einer map sind in Go Kopien: erst herausholen, ändern,
// dann wieder hineinschreiben.
u := ergebnis[projekt]
switch quelle {
u := result[project]
switch source {
case SourceWeb:
u.Human = u.Human.add(z)
case SourceMCP:
u.Agent = u.Agent.add(z)
}
ergebnis[projekt] = u
result[project] = u
}
if err := rows.Err(); err != nil {
rows.Close()
@ -176,38 +176,38 @@ func (s *Store) UsageSummaries(ctx context.Context, projectID string, jetzt time
// NOT EXISTS fragt je Eintrag, ob es in entry_tags gar keine Zeile für ihn
// gibt. Das läuft über den Primärschlüssel (entry_id, tag) und ist damit
// ein kurzer Blick in den Index je Eintrag.
abfrage = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0),
query = `SELECT project_id, COUNT(*), COALESCE(SUM(curated), 0),
SUM(CASE WHEN NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id) THEN 1 ELSE 0 END),
MAX(updated_at)
FROM entries e
WHERE e.deleted_at IS NULL`
werte = nil
values = nil
if projectID != "" {
abfrage += ` AND project_id = ?`
werte = append(werte, projectID)
query += ` AND project_id = ?`
values = append(values, projectID)
}
abfrage += ` GROUP BY project_id`
query += ` GROUP BY project_id`
rows, err = s.lesen.QueryContext(ctx, abfrage, werte...)
rows, err = s.read.QueryContext(ctx, query, values...)
if err != nil {
return nil, fmt.Errorf("Bestand konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var projekt string
var zuletzt sql.NullString
var anzahl, kuratiert, ohneSchlagwort int
if err := rows.Scan(&projekt, &anzahl, &kuratiert, &ohneSchlagwort, &zuletzt); err != nil {
var project string
var latest sql.NullString
var count, curated, untagged int
if err := rows.Scan(&project, &count, &curated, &untagged, &latest); err != nil {
return nil, err
}
u := ergebnis[projekt]
u.Entries, u.Curated, u.Untagged = anzahl, kuratiert, ohneSchlagwort
if zuletzt.Valid {
u.LastUpdatedAt = parseTime(zuletzt.String)
u := result[project]
u.Entries, u.Curated, u.Untagged = count, curated, untagged
if latest.Valid {
u.LastUpdatedAt = parseTime(latest.String)
}
ergebnis[projekt] = u
result[project] = u
}
return ergebnis, rows.Err()
return result, rows.Err()
}
// EntryRef ist ein knapper Verweis auf einen Eintrag: Bezeichner und Titel.
@ -219,7 +219,7 @@ type EntryRef struct {
// UntaggedEntries gibt Einträge ohne Schlagwort zurück, zuletzt geänderte
// zuerst, höchstens limit Stück.
func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int) ([]EntryRef, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT slug, title FROM entries e
WHERE project_id = ? AND e.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM entry_tags t WHERE t.entry_id = e.id)
@ -232,15 +232,15 @@ func (s *Store) UntaggedEntries(ctx context.Context, projectID string, limit int
}
defer rows.Close()
var liste []EntryRef
var list []EntryRef
for rows.Next() {
var e EntryRef
if err := rows.Scan(&e.Slug, &e.Title); err != nil {
return nil, err
}
liste = append(liste, e)
list = append(list, e)
}
return liste, rows.Err()
return list, rows.Err()
}
// MissingLink ist ein Bezeichner, auf den verwiesen wird, zu dem es aber
@ -259,7 +259,7 @@ type MissingLink struct {
// Verweise AUS gelöschten Einträgen zählen nicht; Verweise AUF gelöschte
// Einträge dagegen schon — für alle Leser ist das Ziel ja weg.
func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLink, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT l.to_slug, COUNT(*) AS anzahl
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
@ -276,22 +276,22 @@ func (s *Store) MissingLinks(ctx context.Context, projectID string) ([]MissingLi
}
defer rows.Close()
var liste []MissingLink
var list []MissingLink
for rows.Next() {
var m MissingLink
if err := rows.Scan(&m.Slug, &m.Count); err != nil {
return nil, err
}
liste = append(liste, m)
list = append(list, m)
}
return liste, rows.Err()
return list, rows.Err()
}
// RecentlyUsedKeys gibt die zuletzt benutzten, nicht zurückgezogenen Keys
// eines Projekts zurück, höchstens limit Stück. Nie benutzte Keys fehlen —
// die gehören in eine andere Frage (AUSBAUSTUFEN.md 4.7).
func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit int) ([]APIKey, error) {
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT `+keyColumns+` FROM api_keys
WHERE project_id = ? AND revoked_at = '' AND last_used_at <> ''
ORDER BY last_used_at DESC
@ -345,7 +345,7 @@ func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit in
// SQLite (es nimmt die übrigen Spalten dann aus der Zeile mit dem
// Höchstwert); PostgreSQL lehnt es ab. Fensterfunktionen können beide
// (SQLite ab 3.25), siehe AUSBAUSTUFEN.md 4.10.
rows, err := s.lesen.QueryContext(ctx,
rows, err := s.read.QueryContext(ctx,
`SELECT entry_slug, title, actor, detail, ts FROM (
SELECT a.entry_slug, e.title, a.actor, a.detail, a.ts,
ROW_NUMBER() OVER (PARTITION BY a.entry_slug ORDER BY a.ts DESC, a.id DESC) AS nr
@ -364,17 +364,17 @@ func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit in
}
defer rows.Close()
var liste []CuratedOverride
var list []CuratedOverride
for rows.Next() {
var c CuratedOverride
var detail, zeitpunkt string
if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &zeitpunkt); err != nil {
var detail, at string
if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &at); err != nil {
return nil, err
}
c.Reason = strings.TrimSpace(strings.TrimPrefix(
strings.TrimPrefix(detail, DetailCuratedOverridden), ":"))
c.At = parseTime(zeitpunkt)
liste = append(liste, c)
c.At = parseTime(at)
list = append(list, c)
}
return liste, rows.Err()
return list, rows.Err()
}

View file

@ -8,41 +8,41 @@ import (
"unicode/utf8"
)
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und
// TestUsageSummariesCountsWrites: Neuanlagen, Aktualisierungen und
// Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und
// nur innerhalb des Zeitfensters.
func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) {
func TestUsageSummariesCountsWrites(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
protokoll := func(quelle, aktion, detail string) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail})
log := func(source, action, detail string) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: action, Source: source, Detail: detail})
}
protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb")
protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated)
protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung
protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso
protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung
protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht
log(SourceMCP, ActionEntryWrite, DetailEntryCreated)
log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
log(SourceMCP, ActionEntryDelete, "in den Papierkorb")
log(SourceWeb, ActionEntryWrite, DetailEntryCreated)
log(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung
log(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso
log(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung
log(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht
// Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.write.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`,
NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil {
NewID(), old, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil {
t.Fatal(err)
}
alle, err := s.UsageSummaries(ctx, "", time.Now())
all, err := s.UsageSummaries(ctx, "", time.Now())
if err != nil {
t.Fatal(err)
}
u := alle[p.ID]
u := all[p.ID]
if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) {
t.Errorf("Agent = %+v", u.Agent)
@ -56,15 +56,15 @@ func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) {
}
// Eingeschränkt auf das Projekt kommt dasselbe heraus.
einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now())
if einzeln[p.ID] != u {
t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[p.ID], u)
single, _ := s.UsageSummaries(ctx, p.ID, time.Now())
if single[p.ID] != u {
t.Errorf("mit Projektfilter: %+v, ohne: %+v", single[p.ID], u)
}
}
// TestUsageSummariesBestand: Einträge, kuratierte und solche ohne Schlagwort —
// TestUsageSummariesInventory: Einträge, kuratierte und solche ohne Schlagwort —
// ohne Papierkorb.
func TestUsageSummariesBestand(t *testing.T) {
func TestUsageSummariesInventory(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -75,8 +75,8 @@ func TestUsageSummariesBestand(t *testing.T) {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
alle, _ := s.UsageSummaries(ctx, "", time.Now())
u := alle[p.ID]
all, _ := s.UsageSummaries(ctx, "", time.Now())
u := all[p.ID]
if u.Entries != 3 || u.Curated != 1 || u.Untagged != 2 {
t.Errorf("Bestand = %d Einträge, %d kuratiert, %d ohne Schlagwort; erwartet 3, 1, 2",
@ -93,9 +93,9 @@ func TestUsageSummariesBestand(t *testing.T) {
t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent())
}
ohne, _ := s.UntaggedEntries(ctx, p.ID, 10)
if len(ohne) != 2 {
t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne)
without, _ := s.UntaggedEntries(ctx, p.ID, 10)
if len(without) != 2 {
t.Errorf("UntaggedEntries = %v, erwartet b und c", without)
}
}
@ -114,17 +114,17 @@ func TestMissingLinks(t *testing.T) {
s.MoveEntryToTrash(ctx, p.ID, "geloescht", "tom")
s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "tom")
liste, err := s.MissingLinks(ctx, p.ID)
list, err := s.MissingLinks(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}}
if len(liste) != len(erwartet) {
t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet)
want := []MissingLink{{"fehlt", 2}, {"geloescht", 1}}
if len(list) != len(want) {
t.Fatalf("MissingLinks = %v, erwartet %v", list, want)
}
for i := range erwartet {
if liste[i] != erwartet[i] {
t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i])
for i := range want {
if list[i] != want[i] {
t.Errorf("[%d] = %v, erwartet %v", i, list[i], want[i])
}
}
}
@ -134,13 +134,13 @@ func TestRecentlyUsedKeys(t *testing.T) {
ctx := context.Background()
p := testProject(t, s)
benutzt, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write")
used, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write")
s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write")
weg, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write")
gone, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write")
jetzt := NowString()
s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID)
s.RevokeAPIKey(ctx, weg.ID)
now := NowString()
s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, now, used.ID, gone.ID)
s.RevokeAPIKey(ctx, gone.ID)
keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5)
if err != nil {
@ -151,12 +151,12 @@ func TestRecentlyUsedKeys(t *testing.T) {
}
}
// TestCuratedOverrideDetailKuerzt prüft, dass eine ausufernde Begründung
// TestCuratedOverrideDetailTruncates prüft, dass eine ausufernde Begründung
// gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht.
func TestCuratedOverrideDetailKuerzt(t *testing.T) {
lang := strings.Repeat("ü", maxBegruendung+50)
func TestCuratedOverrideDetailTruncates(t *testing.T) {
long := strings.Repeat("ü", maxJustification+50)
got := CuratedOverrideDetail(lang)
got := CuratedOverrideDetail(long)
if !strings.HasPrefix(got, DetailCuratedOverridden+": ") {
t.Errorf("Anfang fehlt: %q", got[:40])
@ -164,19 +164,19 @@ func TestCuratedOverrideDetailKuerzt(t *testing.T) {
if !utf8.ValidString(got) {
t.Error("die Kürzung hat ein Zeichen zerbrochen")
}
if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxBegruendung+1 {
if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxJustification+1 {
t.Errorf("Detail ist %d Zeichen lang, nicht gekürzt", n)
}
}
// TestCuratedOverridesJuengsterVorgang prüft, dass bei mehreren Änderungen an
// TestCuratedOverridesLatestEvent prüft, dass bei mehreren Änderungen an
// einem Eintrag Zugang und Begründung der JÜNGSTEN Änderung erscheinen — und
// der Eintrag nur einmal.
//
// Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne
// Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest
// gesetzt, damit die Reihenfolge eindeutig ist.
func TestCuratedOverridesJuengsterVorgang(t *testing.T) {
func TestCuratedOverridesLatestEvent(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -185,39 +185,39 @@ func TestCuratedOverridesJuengsterVorgang(t *testing.T) {
t.Fatal(err)
}
jetzt := time.Now().UTC()
now := time.Now().UTC()
for _, v := range []struct {
alter time.Duration
zugang, grund string
age time.Duration
key, reason string
}{
{2 * time.Hour, "agent-alt", "alter Grund"},
{1 * time.Minute, "agent-neu", "neuer Grund"},
{1 * time.Hour, "agent-mitte", "mittlerer Grund"},
} {
if _, err := s.schreiben.ExecContext(ctx,
if _, err := s.write.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`,
NewID(), jetzt.Add(-v.alter).Format(time.RFC3339Nano), p.ID, v.zugang,
ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.grund)); err != nil {
NewID(), now.Add(-v.age).Format(time.RFC3339Nano), p.ID, v.key,
ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.reason)); err != nil {
t.Fatal(err)
}
}
liste, err := s.CuratedOverrides(ctx, p.ID, 10)
list, err := s.CuratedOverrides(ctx, p.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(liste) != 1 {
t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(liste), liste)
if len(list) != 1 {
t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(list), list)
}
if liste[0].Actor != "agent-neu" || liste[0].Reason != "neuer Grund" {
t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", liste[0])
if list[0].Actor != "agent-neu" || list[0].Reason != "neuer Grund" {
t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", list[0])
}
}
// TestCuratedOverridesOhneGeloeschte prüft, dass Einträge im Papierkorb nicht
// TestCuratedOverridesWithoutDeleted prüft, dass Einträge im Papierkorb nicht
// zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen.
func TestCuratedOverridesOhneGeloeschte(t *testing.T) {
func TestCuratedOverridesWithoutDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
@ -228,13 +228,13 @@ func TestCuratedOverridesOhneGeloeschte(t *testing.T) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite,
EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")})
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 1 {
t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(liste))
if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 1 {
t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(list))
}
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil {
t.Fatal(err)
}
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 0 {
t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", liste)
if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 0 {
t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", list)
}
}

View file

@ -1,90 +0,0 @@
package store
import (
"context"
"database/sql"
)
// pool ist ein Verbindungspool samt dem Dialekt seiner Datenbank.
//
// # Warum nicht direkt *sql.DB?
//
// Jede Abfrage muss durch dialekt.rebind, bevor sie an die Datenbank geht —
// sonst bekommt PostgreSQL ein "?", mit dem es nichts anfangen kann. Würde der
// Store *sql.DB direkt benutzen, wäre das bei jeder der rund 100 Abfragen eine
// Stelle, an der man es vergessen kann.
//
// pool bietet deshalb nur die Methoden an, die der Store braucht, und jede
// davon wandelt um. An *sql.DB selbst kommt außerhalb dieser Datei niemand
// heran — eine Abfrage am Dialekt vorbei lässt sich gar nicht erst schreiben.
type pool struct {
db *sql.DB
d dialekt
}
func (p pool) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) {
return p.db.ExecContext(ctx, p.d.rebind(abfrage), args...)
}
func (p pool) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) {
return p.db.QueryContext(ctx, p.d.rebind(abfrage), args...)
}
func (p pool) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row {
return p.db.QueryRowContext(ctx, p.d.rebind(abfrage), args...)
}
// beginTx startet eine Transaktion, deren Abfragen ebenfalls umgewandelt
// werden.
func (p pool) beginTx(ctx context.Context) (*transaktion, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return &transaktion{tx: tx, d: p.d}, nil
}
func (p pool) Close() error { return p.db.Close() }
// Verbindungen sind die Zahlen eines Lese- und eines Schreibpools, wie
// database/sql sie führt — für die Systemseite (AUSBAUSTUFEN.md 5.12).
type Verbindungen struct {
Lesen, Schreiben sql.DBStats
}
// Offen zählt die offenen Verbindungen beider Pools zusammen. Genau so viele
// belegt Munin gerade beim Datenbankserver.
func (v Verbindungen) Offen() int { return v.Lesen.OpenConnections + v.Schreiben.OpenConnections }
// InBenutzung zählt die Verbindungen, die gerade eine Abfrage ausführen.
func (v Verbindungen) InBenutzung() int { return v.Lesen.InUse + v.Schreiben.InUse }
// Gewartet zählt, wie oft eine Anfrage auf eine freie Verbindung warten
// musste, seit der Pool geöffnet wurde. Wächst die Zahl stetig, sind die
// Pools zu klein für die Last.
func (v Verbindungen) Gewartet() int64 { return v.Lesen.WaitCount + v.Schreiben.WaitCount }
// transaktion ist das Gegenstück zu pool für *sql.Tx: dieselben Methoden,
// dieselbe Umwandlung.
type transaktion struct {
tx *sql.Tx
d dialekt
}
func (t *transaktion) ExecContext(ctx context.Context, abfrage string, args ...any) (sql.Result, error) {
return t.tx.ExecContext(ctx, t.d.rebind(abfrage), args...)
}
func (t *transaktion) QueryContext(ctx context.Context, abfrage string, args ...any) (*sql.Rows, error) {
return t.tx.QueryContext(ctx, t.d.rebind(abfrage), args...)
}
func (t *transaktion) QueryRowContext(ctx context.Context, abfrage string, args ...any) *sql.Row {
return t.tx.QueryRowContext(ctx, t.d.rebind(abfrage), args...)
}
func (t *transaktion) Commit() error { return t.tx.Commit() }
// Rollback nach einem erfolgreichen Commit ist wirkungslos — deshalb kann es
// bedenkenlos per defer stehen.
func (t *transaktion) Rollback() error { return t.tx.Rollback() }

View file

@ -34,8 +34,8 @@ func (a WorkspaceAdmin) Revoked() bool {
//
// Bei einem Workspace-Admin wiegt das schwerer als bei einem Projekt-Key: Ein
// vergessenes Token öffnet nicht ein Projekt, sondern den ganzen Workspace.
func (a WorkspaceAdmin) Unused(jetzt time.Time) bool {
return ungenutzt(a.Revoked(), a.LastUsedAt, a.CreatedAt, jetzt)
func (a WorkspaceAdmin) Unused(now time.Time) bool {
return unused(a.Revoked(), a.LastUsedAt, a.CreatedAt, now)
}
// workspaceAdminColumns steht in jeder Abfrage auf workspace_admins.
@ -71,17 +71,17 @@ func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, na
Name: name,
Hash: hash,
}
jetzt := NowString()
now := NowString()
if _, err := c.schreiben.ExecContext(ctx,
if _, err := c.write.ExecContext(ctx,
`INSERT INTO workspace_admins (id, workspace_id, name, hash, created_at)
VALUES (?, ?, ?, ?, ?)`,
a.ID, a.WorkspaceID, a.Name, a.Hash, jetzt,
a.ID, a.WorkspaceID, a.Name, a.Hash, now,
); err != nil {
return WorkspaceAdmin{}, fmt.Errorf("Workspace-Admin konnte nicht angelegt werden: %w", err)
}
a.CreatedAt = parseTime(jetzt)
a.CreatedAt = parseTime(now)
return a, nil
}
@ -90,7 +90,7 @@ func (c *ControlStore) CreateWorkspaceAdmin(ctx context.Context, workspaceID, na
//
// Zurückgezogene Zugänge gelten als nicht vorhanden, wie bei ResolveKeyHash.
func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash string) (WorkspaceAdmin, error) {
row := c.lesen.QueryRowContext(ctx,
row := c.read.QueryRowContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE hash = ? AND revoked_at = ''`, hash)
a, err := scanWorkspaceAdmin(row.Scan)
@ -106,7 +106,7 @@ func (c *ControlStore) ResolveWorkspaceAdminHash(ctx context.Context, hash strin
// WorkspaceAdminByID liest einen Zugang anhand seiner ID — auch einen
// zurückgezogenen. Ob er noch gilt, entscheidet der Aufrufer über Revoked().
func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (WorkspaceAdmin, error) {
row := c.lesen.QueryRowContext(ctx,
row := c.read.QueryRowContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE id = ?`, id)
a, err := scanWorkspaceAdmin(row.Scan)
@ -122,7 +122,7 @@ func (c *ControlStore) WorkspaceAdminByID(ctx context.Context, id string) (Works
// ListWorkspaceAdmins gibt alle Zugänge eines Workspace zurück, neueste zuerst.
// Zurückgezogene sind dabei, damit die Verwaltung sie noch anzeigen kann.
func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID string) ([]WorkspaceAdmin, error) {
rows, err := c.lesen.QueryContext(ctx,
rows, err := c.read.QueryContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins
WHERE workspace_id = ? ORDER BY created_at DESC`, workspaceID)
if err != nil {
@ -145,7 +145,7 @@ func (c *ControlStore) ListWorkspaceAdmins(ctx context.Context, workspaceID stri
// für die Übersicht des Plattform-Admins, in einer Abfrage statt einer je
// Workspace.
func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]WorkspaceAdmin, error) {
rows, err := c.lesen.QueryContext(ctx,
rows, err := c.read.QueryContext(ctx,
`SELECT `+workspaceAdminColumns+` FROM workspace_admins WHERE revoked_at = ''`)
if err != nil {
return nil, fmt.Errorf("Workspace-Admins konnten nicht gelesen werden: %w", err)
@ -168,7 +168,7 @@ func (c *ControlStore) ListActiveWorkspaceAdmins(ctx context.Context) ([]Workspa
// Die Workspace-ID steht mit in der Bedingung: Eine untergeschobene ID aus
// einem anderen Workspace trifft dann keine Zeile und endet als ErrNotFound.
func (c *ControlStore) RevokeWorkspaceAdmin(ctx context.Context, workspaceID, id string) error {
res, err := c.schreiben.ExecContext(ctx,
res, err := c.write.ExecContext(ctx,
`UPDATE workspace_admins SET revoked_at = ?
WHERE id = ? AND workspace_id = ? AND revoked_at = ''`,
NowString(), id, workspaceID,
@ -185,7 +185,7 @@ func (c *ControlStore) TouchWorkspaceAdmin(ctx context.Context, id string, lastU
if time.Since(lastUsed) < time.Hour {
return nil
}
_, err := c.schreiben.ExecContext(ctx,
_, err := c.write.ExecContext(ctx,
`UPDATE workspace_admins SET last_used_at = ? WHERE id = ?`, NowString(), id)
return err
}

191
internal/web/chart.go Normal file
View file

@ -0,0 +1,191 @@
package web
import (
"fmt"
"munin/internal/stats"
)
// Balkendiagramme für die Systemseite (AUSBAUSTUFEN.md 5.16).
//
// # Warum selbst gezeichnet?
//
// Eine Diagramm-Bibliothek wäre JavaScript, das erst geladen werden und unter
// der Content-Security-Policy laufen muss — für 30 Balken. Stattdessen rechnet
// der Server die Rechtecke aus, und das Template schreibt sie als SVG direkt
// in die Seite. Der Browser zeichnet SVG wie HTML, ohne ein Skript.
//
// Die Farben kommen aus style.css über Klassen (keine style-Attribute — die
// verbietet die CSP), damit das dunkle Farbschema von selbst passt. Fährt man
// mit der Maus über einen Tag, zeigt der Browser dessen <title> an.
//
// Alle Maße sind ganze Zahlen im Koordinatensystem der viewBox. Das SVG
// skaliert mit der Breite der Seite; die Zahlen sind also keine Pixel.
// Maße des Diagramms in viewBox-Einheiten.
const (
chartLeft = 36 // Platz für die Zahlen der y-Achse
chartTop = 8 // damit die oberste Zahl nicht abgeschnitten wird
chartArea = 120 // Höhe der Balkenfläche
chartBottom = 20 // Platz für die Daten der x-Achse
chartColumn = 20 // Breite je Tag
chartBarWidth = 14 // Breite des Balkens darin, der Rest ist Abstand
)
// chart ist ein fertig ausgerechnetes Balkendiagramm.
type chart struct {
Title string // für Screenreader (aria-label)
Width, Height int // viewBox
Links, Right int // x der Balkenfläche: Anfang und Ende
Baseline int // y der Nulllinie
Empty bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen
Grid []chartLine
Bars []chartBarDay
Axis []chartLabel
}
// chartLine ist eine waagerechte Hilfslinie mit ihrem Wert.
type chartLine struct {
Y int
Text string
}
// chartBarDay ist ein Tag: eine unsichtbare Trefferfläche über die
// ganze Höhe (damit der Tooltip auch bei kleinen Balken erscheint) und die
// gestapelten Teile.
type chartBarDay struct {
X, Width int // Trefferfläche
Title string
Parts []chartPart
}
// chartPart ist ein Rechteck eines gestapelten Balkens.
type chartPart struct {
X, Y, Width, Height int
Class string
}
// chartLabel ist ein Datum unter der x-Achse.
type chartLabel struct {
X int
Text string
}
// chartSeries ist eine Teilmenge eines Balkens: welcher Wert, in welcher
// Farbe, wie heißt er im Tooltip.
type chartSeries struct {
value func(stats.Tag) int
class string
name string
}
// weekdays für den Tooltip, mit Sonntag = 0 wie time.Weekday.
var weekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
// chartUnit ist das Wort hinter der Summe im Tooltip, in Einzahl und
// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge".
type chartUnit struct{ one, more string }
// buildChart rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken,
// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen.
func buildChart(title string, unit chartUnit, days []stats.Tag, series ...chartSeries) chart {
d := chart{
Title: title,
Links: chartLeft,
Right: chartLeft + len(days)*chartColumn,
Baseline: chartTop + chartArea,
}
d.Width = d.Right + 4
d.Height = d.Baseline + chartBottom
total := func(t stats.Tag) int {
n := 0
for _, r := range series {
n += r.value(t)
}
return n
}
highest := 0
for _, t := range days {
highest = max(highest, total(t))
}
d.Empty = highest == 0
scale := round125(highest)
// Hilfslinien bei der Hälfte und ganz oben. Die Hälfte nur, wenn sie
// eine ganze Zahl ist — "2,5 Suchen" wäre seltsam.
d.Grid = append(d.Grid, chartLine{Y: d.Baseline, Text: "0"})
if scale%2 == 0 {
d.Grid = append(d.Grid, chartLine{Y: d.Baseline - chartArea/2, Text: fmt.Sprint(scale / 2)})
}
d.Grid = append(d.Grid, chartLine{Y: chartTop, Text: fmt.Sprint(scale)})
for i, t := range days {
column := chartLeft + i*chartColumn
tag := chartBarDay{X: column, Width: chartColumn}
// Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)".
// Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl.
tag.Title = fmt.Sprintf("%s %s: %s", weekdays[t.Date.Weekday()], t.Date.Format("02.01."),
plural(total(t), unit.one, unit.more))
if len(series) > 1 && total(t) > 0 {
tag.Title += " ("
for j, r := range series {
if j > 0 {
tag.Title += ", "
}
tag.Title += fmt.Sprintf("%s %d", r.name, r.value(t))
}
tag.Title += ")"
}
// Die Teile stapeln: y wächst in SVG nach unten, also von der
// Nulllinie aus nach oben abziehen.
top := d.Baseline
for _, r := range series {
h := heightOf(r.value(t), scale)
if h == 0 {
continue
}
top -= h
tag.Parts = append(tag.Parts, chartPart{
X: column + (chartColumn-chartBarWidth)/2, Y: top,
Width: chartBarWidth, Height: h, Class: r.class,
})
}
d.Bars = append(d.Bars, tag)
// Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht
// heute immer unten rechts.
if (len(days)-1-i)%7 == 0 {
d.Axis = append(d.Axis, chartLabel{X: column + chartColumn/2, Text: t.Date.Format("02.01.")})
}
}
return d
}
// heightOf rechnet einen Wert in die Höhe seines Balkens um. Ein Wert über
// null bekommt mindestens eine Einheit, sonst verschwände ein einzelner
// Schreibvorgang neben einem Tag mit 500.
func heightOf(value, scale int) int {
if value <= 0 {
return 0
}
return max(1, value*chartArea/scale)
}
// round125 rundet auf die nächste "glatte" Zahl auf: 1, 2, 5, 10, 20, 50, …
// So endet die y-Achse bei einem Wert, den man gut lesen kann, statt bei 37.
func round125(n int) int {
if n <= 1 {
return 1
}
for level := 1; ; level *= 10 {
for _, f := range []int{1, 2, 5} {
if f*level >= n {
return f * level
}
}
}
}

View file

@ -0,0 +1,82 @@
package web
import (
"testing"
"time"
"munin/internal/stats"
)
func TestRound125(t *testing.T) {
for _, tc := range []struct{ in, out int }{
{0, 1}, {1, 1}, {2, 2}, {3, 5}, {5, 5}, {6, 10}, {11, 20}, {37, 50}, {51, 100}, {999, 1000}, {1001, 2000},
} {
if got := round125(tc.in); got != tc.out {
t.Errorf("runde125(%d) = %d, erwartet %d", tc.in, got, tc.out)
}
}
}
// TestChart: Die Balken stapeln sich von der Nulllinie nach oben, der
// höchste reicht bis zur Skala, ein kleiner Wert bleibt sichtbar, und unter
// dem letzten Tag steht ein Datum.
func TestChart(t *testing.T) {
tag0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
days := make([]stats.Tag, 8)
for i := range days {
days[i].Date = tag0.AddDate(0, 0, i)
}
days[3].AgentWrites, days[3].HumanWrites = 6, 4 // Summe 10 = Skala
days[5].AgentWrites = 1
d := buildChart("Test", chartUnit{"Schreibvorgang", "Schreibvorgänge"}, days,
chartSeries{value: func(t stats.Tag) int { return t.AgentWrites }, class: "a", name: "Agenten"},
chartSeries{value: func(t stats.Tag) int { return t.HumanWrites }, class: "m", name: "Menschen"},
)
if d.Empty || len(d.Bars) != len(days) {
t.Fatalf("Leer=%v, %d Balken", d.Empty, len(d.Bars))
}
full := d.Bars[3].Parts
if len(full) != 2 {
t.Fatalf("Tag 3: %d Teile, erwartet 2", len(full))
}
if full[0].Y+full[0].Height != d.Baseline {
t.Error("unterster Teil steht nicht auf der Nulllinie")
}
if full[1].Y+full[1].Height != full[0].Y {
t.Error("zweiter Teil liegt nicht auf dem ersten")
}
if full[1].Y != chartTop {
t.Errorf("höchster Balken endet bei y=%d, erwartet %d (die Skala)", full[1].Y, chartTop)
}
if small := d.Bars[5].Parts; len(small) != 1 || small[0].Height < 1 {
t.Errorf("ein einzelner Schreibvorgang ist unsichtbar: %+v", small)
}
if len(d.Bars[0].Parts) != 0 {
t.Error("ein Tag ohne Aktivität hat Balken")
}
if got := d.Bars[3].Title; got != "So 04.10.: 10 Schreibvorgänge (Agenten 6, Menschen 4)" {
t.Errorf("Tooltip %q", got)
}
if last := d.Axis[len(d.Axis)-1]; last.Text != "08.10." {
t.Errorf("letzte Beschriftung %q, erwartet den letzten Tag", last.Text)
}
if empty := buildChart("Leer", chartUnit{"Suche", "Suchen"}, days[:2]); !empty.Empty {
t.Error("Diagramm ohne Werte ist nicht als leer markiert")
}
}
func TestByteSize(t *testing.T) {
for _, tc := range []struct {
in int64
out string
}{
{0, "0 B"}, {1023, "1023 B"}, {1536, "1,5 KB"}, {812 << 10, "812 KB"}, {3 << 30, "3,0 GB"},
} {
if got := byteSize(tc.in); got != tc.out {
t.Errorf("groesse(%d) = %q, erwartet %q", tc.in, got, tc.out)
}
}
}

Some files were not shown because too many files have changed in this diff Show more