502 lines
16 KiB
Go
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
|
|
}
|