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 }