munin/internal/store/entries.go
2026-09-15 20:36:26 +02:00

502 lines
16 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
)
// EntryWrite beschreibt einen Schreibvorgang auf einem Eintrag.
//
// Ein eigener Typ statt vieler Parameter: Die Anzahl der Felder wächst mit der
// Zeit, und ein benanntes Feld am Aufrufort ist beim Lesen sofort verständlich —
// anders als das fünfte Argument einer langen Parameterliste.
type EntryWrite struct {
ProjectID string
Slug string
Title string
Content string
Tags []string
Source string // SourceHuman oder SourceAgent
Author string // Name des verwendeten API-Keys
Curated bool
// ExpectedVersion aktiviert das optimistische Sperren: Ist der Wert größer
// als 0 und stimmt nicht mit dem gespeicherten Stand überein, schlägt der
// Schreibvorgang mit ErrConflict fehl.
//
// Damit können zwei Agenten, die denselben Eintrag bearbeiten, sich nicht
// mehr gegenseitig stillschweigend überschreiben — der zweite erfährt, dass
// sich etwas geändert hat, und kann neu lesen.
ExpectedVersion int
}
// entryColumns steht in jeder Eintrags-Abfrage.
const entryColumns = `id, project_id, slug, title, content, source, author, curated, version, created_at, updated_at`
// scanEntry liest eine Eintragszeile. Die Schlagworte kommen aus einer eigenen
// Tabelle und werden separat nachgeladen (siehe attachTags).
func scanEntry(scan func(...any) error) (Entry, error) {
var e Entry
var curated int
var createdAt, updatedAt string
if err := scan(&e.ID, &e.ProjectID, &e.Slug, &e.Title, &e.Content,
&e.Source, &e.Author, &curated, &e.Version, &createdAt, &updatedAt); err != nil {
return Entry{}, err
}
e.Curated = curated == 1
e.CreatedAt = parseTime(createdAt)
e.UpdatedAt = parseTime(updatedAt)
return e, nil
}
// NormalizeTags bringt Schlagworte auf eine einheitliche Form: klein, ohne
// Leerzeichen am Rand, ohne Dubletten, sortiert.
//
// Ohne das hätte man "Deployment", "deployment" und " deployment " als drei
// 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
for _, t := range tags {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || gesehen[t] {
continue
}
gesehen[t] = true
sauber = append(sauber, t)
}
sort.Strings(sauber)
return sauber
}
// PutEntry legt einen Eintrag an oder aktualisiert ihn — der Slug entscheidet.
//
// Genau das macht den Slug zum Werkzeug gegen Dubletten: Ein Agent, der zum
// zweiten Mal etwas über dasselbe Thema ablegt, verwendet denselben Slug und
// aktualisiert damit, statt einen Widerspruch daneben zu stellen.
//
// Alles läuft in einer Transaktion: Eintrag, Schlagworte und Verweise sind
// entweder vollständig geschrieben oder gar nicht.
func (s *Store) PutEntry(ctx context.Context, w EntryWrite) (Entry, error) {
if !ValidSlug(w.Slug) {
return Entry{}, fmt.Errorf("ungültiger Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", w.Slug)
}
if strings.TrimSpace(w.Title) == "" {
return Entry{}, errors.New("der Eintrag braucht einen Titel")
}
if w.Source != SourceHuman && w.Source != SourceAgent {
return Entry{}, fmt.Errorf("unbekannte Herkunft %q", w.Source)
}
tags := NormalizeTags(w.Tags)
links := ExtractLinks(w.Content)
jetzt := NowString()
var ergebnis Entry
err := s.tx(ctx, func(tx *sql.Tx) error {
// Bestehenden Eintrag suchen, um zwischen Anlegen und Ändern zu unterscheiden.
var id, createdAt string
var version int
err := tx.QueryRowContext(ctx,
`SELECT id, version, created_at FROM entries WHERE project_id = ? AND slug = ?`,
w.ProjectID, w.Slug,
).Scan(&id, &version, &createdAt)
switch {
case errors.Is(err, sql.ErrNoRows):
// Neuer Eintrag.
//
// Ein ExpectedVersion > 0 bedeutet: "ich habe Stand N gelesen und
// will ihn fortschreiben". Existiert der Eintrag nicht (mehr), ist
// das ein Konflikt und kein stillschweigendes Neuanlegen.
if w.ExpectedVersion > 0 {
return ErrConflict
}
id = NewID()
version = 1
createdAt = jetzt
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,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht angelegt werden: %w", err)
}
case err != nil:
return fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
default:
// Vorhandener Eintrag.
if w.ExpectedVersion > 0 && w.ExpectedVersion != version {
return fmt.Errorf("%w (gelesen: %d, aktuell: %d)", ErrConflict, w.ExpectedVersion, version)
}
version++
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET title = ?, content = ?, source = ?, author = ?, curated = ?, version = ?, updated_at = ?
WHERE id = ?`,
w.Title, w.Content, w.Source, w.Author, boolToInt(w.Curated), version, jetzt, id,
); err != nil {
return fmt.Errorf("Eintrag konnte nicht geändert werden: %w", err)
}
}
// Schlagworte und Verweise werden nicht abgeglichen, sondern ersetzt.
// Das ist einfacher als ein Differenzabgleich und bei dieser Datenmenge
// genauso schnell — der Eintrag ist die Wahrheit, nicht die Historie.
if err := replaceTags(ctx, tx, w.ProjectID, id, tags); err != nil {
return err
}
if err := replaceLinks(ctx, tx, w.ProjectID, id, links); err != nil {
return err
}
ergebnis = Entry{
ID: id,
ProjectID: w.ProjectID,
Slug: w.Slug,
Title: w.Title,
Content: w.Content,
Tags: tags,
Source: w.Source,
Author: w.Author,
Curated: w.Curated,
Version: version,
CreatedAt: parseTime(createdAt),
UpdatedAt: parseTime(jetzt),
}
return nil
})
if err != nil {
return Entry{}, err
}
return ergebnis, nil
}
// 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
// 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 *sql.Tx, 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)
}
for _, tag := range tags {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_tags (entry_id, project_id, tag) VALUES (?, ?, ?)`, entryID, projectID, tag,
); err != nil {
return fmt.Errorf("Schlagwort %q konnte nicht gespeichert werden: %w", tag, err)
}
}
return nil
}
// replaceLinks ersetzt die ausgehenden Verweise eines Eintrags.
func replaceLinks(ctx context.Context, tx *sql.Tx, 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 {
if _, err := tx.ExecContext(ctx,
`INSERT INTO entry_links (from_entry_id, project_id, to_slug) VALUES (?, ?, ?)`,
entryID, projectID, ziel,
); err != nil {
return fmt.Errorf("Verweis auf %q konnte nicht gespeichert werden: %w", ziel, err)
}
}
return nil
}
// 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 = ?`,
projectID, slug,
)
e, err := scanEntry(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Entry{}, ErrNotFound
}
if err != nil {
return Entry{}, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if err := s.attachTags(ctx, []*Entry{&e}); err != nil {
return Entry{}, err
}
return e, nil
}
// EntryFilter schränkt ListEntries ein.
type EntryFilter struct {
Tag string // leer = alle
Limit int // 0 = Standardwert
Offset int
Curated bool // true = nur kuratierte Einträge
}
const defaultEntryLimit = 50
// ListEntries gibt Einträge eines Projekts zurück, zuletzt geänderte zuerst.
func (s *Store) ListEntries(ctx context.Context, projectID string, f EntryFilter) ([]Entry, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultEntryLimit
}
// 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 = ?`
werte := []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)))
}
if f.Curated {
abfrage += ` AND curated = 1`
}
abfrage += ` ORDER BY updated_at DESC LIMIT ? OFFSET ?`
werte = append(werte, limit, f.Offset)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var eintraege []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return eintraege, s.attachTagsToSlice(ctx, eintraege)
}
// AllEntries gibt alle Einträge eines Projekts zurück — für den Aufbau des
// Suchindex beim Start und für den Export.
func (s *Store) AllEntries(ctx context.Context, projectID string) ([]Entry, error) {
rows, err := s.lesen.QueryContext(ctx,
`SELECT `+entryColumns+` FROM entries WHERE project_id = ? ORDER BY slug`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var eintraege []Entry
for rows.Next() {
e, err := scanEntry(rows.Scan)
if err != nil {
return nil, err
}
eintraege = append(eintraege, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
return eintraege, s.attachTagsToSlice(ctx, eintraege)
}
// DeleteEntry löscht einen Eintrag samt Schlagworten und Verweisen (per CASCADE).
func (s *Store) DeleteEntry(ctx context.Context, projectID, slug string) error {
res, err := s.schreiben.ExecContext(ctx,
`DELETE FROM entries WHERE project_id = ? AND slug = ?`, projectID, slug,
)
if err != nil {
return fmt.Errorf("Eintrag konnte nicht gelöscht werden: %w", err)
}
return requireOneRow(res)
}
// CountEntries zählt die Einträge eines Projekts.
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 = ?`, projectID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("Einträge konnten nicht gezählt werden: %w", err)
}
return n, nil
}
// ── Schlagworte ──────────────────────────────────────────────────────────────
// ListTags gibt alle Schlagworte eines Projekts mit ihrer Häufigkeit zurück,
// häufigste zuerst.
func (s *Store) ListTags(ctx context.Context, projectID string) ([]TagCount, error) {
// Eine einzige Tabelle statt einer Verknüpfung über entries: Der Aufwand
// hängt jetzt an der Zahl der Schlagworte, nicht an der Zahl der Einträge.
// Siehe migrations/002 — diese Abfrage läuft bei jedem Seitenaufruf, weil
// die Schlagworte in der Seitenleiste stehen.
rows, err := s.lesen.QueryContext(ctx,
`SELECT tag, COUNT(*) AS anzahl
FROM entry_tags
WHERE project_id = ?
GROUP BY tag
ORDER BY anzahl DESC, tag`,
projectID,
)
if err != nil {
return nil, fmt.Errorf("Schlagworte konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var tags []TagCount
for rows.Next() {
var tc TagCount
if err := rows.Scan(&tc.Tag, &tc.Count); err != nil {
return nil, err
}
tags = append(tags, tc)
}
return tags, rows.Err()
}
// TagCount ist ein Schlagwort mit der Anzahl seiner Einträge.
type TagCount struct {
Tag string
Count int
}
// 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]
}
return s.attachTags(ctx, zeiger)
}
// attachTags lädt die Schlagworte mehrerer Einträge in EINER Abfrage nach.
//
// 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 {
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))
for i, e := range eintraege {
werte[i] = e.ID
nachID[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...,
)
if err != nil {
return fmt.Errorf("Schlagworte konnten nicht geladen werden: %w", err)
}
defer rows.Close()
for rows.Next() {
var entryID, tag string
if err := rows.Scan(&entryID, &tag); err != nil {
return err
}
if e, ok := nachID[entryID]; ok {
e.Tags = append(e.Tags, tag)
}
}
return rows.Err()
}
// ── Verweise ─────────────────────────────────────────────────────────────────
// Backlink ist ein Eintrag, der auf einen anderen verweist.
type Backlink struct {
Slug string
Title string
}
// Backlinks gibt alle Einträge zurück, die auf den angegebenen Slug verweisen.
//
// Das ist die Gegenrichtung der [[…]]-Verweise und ersetzt die frühere
// Ordnerhierarchie: Statt "wo liegt diese Seite im Baum" beantwortet es die
// nützlichere Frage "was hat mit dieser Seite zu tun". Für ein KI-Werkzeug ist
// das zugleich der Weg zu verwandtem Kontext.
func (s *Store) Backlinks(ctx context.Context, projectID, slug string) ([]Backlink, error) {
// Ohne ORDER BY in der Abfrage — und das ist kein Schönheitsfehler:
// Mit "ORDER BY e.title" kippt SQLite die Reihenfolge der Verknüpfung und
// läuft durch alle Einträge des Projekts, statt vom kleinen Verweis-Index
// auszugehen. Gemessen bei 5.000 Einträgen: 3,1 ms statt 9 µs.
//
// Sortiert wird deshalb unten in Go. Rückverweise sind erfahrungsgemäß eine
// Handvoll — dort kostet das Sortieren nichts.
rows, err := s.lesen.QueryContext(ctx,
`SELECT e.slug, e.title
FROM entry_links l
JOIN entries e ON e.id = l.from_entry_id
WHERE l.project_id = ? AND l.to_slug = ?`,
projectID, slug,
)
if err != nil {
return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var links []Backlink
for rows.Next() {
var b Backlink
if err := rows.Scan(&b.Slug, &b.Title); err != nil {
return nil, err
}
links = append(links, b)
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.Slice(links, func(i, j int) bool { return links[i].Title < links[j].Title })
return links, nil
}
// boolToInt wandelt einen Wahrheitswert in die Datenbankdarstellung.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}