munin/internal/store/models.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

274 lines
9.5 KiB
Go

package store
import (
"crypto/rand"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
// ErrNotFound meldet, dass ein gesuchter Datensatz nicht existiert.
//
// Ein eigener Fehlerwert statt sql.ErrNoRows: die aufrufenden Schichten sollen
// nicht wissen müssen, dass dahinter überhaupt SQL steckt. Geprüft wird mit
// errors.Is(err, store.ErrNotFound).
var ErrNotFound = errors.New("nicht gefunden")
// ErrConflict meldet einen Schreibkonflikt beim optimistischen Sperren:
// der Eintrag wurde verändert, seit der Schreiber ihn gelesen hat.
var ErrConflict = errors.New("Eintrag wurde zwischenzeitlich geändert")
// ErrDuplicate meldet, dass ein eindeutiger Wert schon vergeben ist
// (Projekt-Slug, Eintrags-Slug).
var ErrDuplicate = errors.New("existiert bereits")
// ── Datentypen ───────────────────────────────────────────────────────────────
// Workspace ist die oberste Klammer. Vorerst gibt es genau einen.
type Workspace struct {
ID string
Name string
CreatedAt time.Time
}
// Project ist die Einheit der Abschottung: eigene Einträge, eigene Keys.
type Project struct {
ID string
WorkspaceID string
Slug string
Name string
Description string
CreatedAt time.Time
// WriteLimitHourly ist die eigene Obergrenze für schreibende MCP-Aufrufe je
// Stunde. 0 bedeutet nicht "gesperrt", sondern "keine eigene Angabe" — dann
// gilt der Standard aus WRITE_LIMIT_HOURLY (siehe auth.WriteBudget).
WriteLimitHourly int
}
// APIKey ist ein Zugangsschlüssel für genau ein Projekt.
// Das Feld für den Klartext-Key fehlt bewusst — gespeichert wird nur der Hash.
type APIKey struct {
ID string
ProjectID string
Name string
Hash string
Role string
CreatedAt time.Time
LastUsedAt time.Time // Nullzeitpunkt = noch nie benutzt
RevokedAt time.Time // Nullzeitpunkt = aktiv
}
// Revoked sagt, ob der Key zurückgezogen wurde.
func (k APIKey) Revoked() bool {
return !k.RevokedAt.IsZero()
}
// UnusedAfter ist die Zeitspanne, nach der ein aktiver Key ohne Benutzung als
// „ungenutzt“ gilt (siehe AUSBAUSTUFEN.md 4.7).
const UnusedAfter = 90 * 24 * time.Hour
// Unused sagt, ob ein aktiver Key seit mehr als UnusedAfter nicht benutzt wurde.
//
// Wurde der Key noch nie benutzt, zählt stattdessen das Anlegedatum. Sonst
// stünde jeder frisch angelegte Key sofort als „ungenutzt“ da, obwohl sein
// Besitzer ihn vielleicht erst morgen einrichtet. Nach 90 Tagen ohne einen
// einzigen Aufruf ist dagegen klar: Den braucht niemand.
//
// 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
// Zeitpunkt vorgeben können.
func (k APIKey) Unused(jetzt time.Time) bool {
if k.Revoked() {
return false
}
zuletzt := k.LastUsedAt
if zuletzt.IsZero() {
zuletzt = k.CreatedAt
}
return jetzt.Sub(zuletzt) > UnusedAfter
}
// Entry ist die einzige Wissenseinheit — von Menschen wie von Agenten geschrieben.
type Entry struct {
ID string
ProjectID string
Slug string
Title string
Content string
Tags []string
Source string // SourceHuman oder SourceAgent
Author string // Name des verwendeten API-Keys
Curated bool // vom Menschen bestätigtes Wissen statt Beobachtung eines Agenten
Version int
CreatedAt time.Time
UpdatedAt time.Time
// RestoredFromTrash setzt nur PutEntry: true, wenn der Schreibvorgang
// einen Eintrag aus dem Papierkorb zurückgeholt und überschrieben hat.
// Aufrufer sagen das dem Schreiber — ein Agent soll merken, dass er einen
// gelöschten Eintrag wiederbelebt hat. Beim Lesen ist der Wert immer false.
RestoredFromTrash bool
}
// StaleAfter ist die Zeitspanne ohne Änderung, nach der ein Eintrag als
// „möglicherweise veraltet“ gilt (siehe AUSBAUSTUFEN.md 3.5).
//
// Ein Jahr ist ein Kompromiss: Ein Zugangsverfahren kann schon nach Wochen
// überholt sein, eine Grundsatzentscheidung nach Jahren noch stimmen. Eine
// Frist je Art des Wissens wäre genauer, ist aber der größere Schritt. Bis
// dahin gilt eine Grenze für alle, und sie ist bewusst eher lang: Ein Vermerk,
// der an jedem zweiten Eintrag steht, liest bald niemand mehr.
const StaleAfter = 365 * 24 * time.Hour
// Stale sagt, ob ein Eintrag seit mehr als StaleAfter nicht geändert wurde.
//
// Das heißt nicht, dass er falsch ist, sondern nur, dass ihn lange niemand
// angefasst hat. Deshalb wird er auch nicht versteckt oder schlechter
// gewichtet. Wer ihn liest, soll aber wissen, dass er ihn vor dem Verwenden
// prüfen sollte.
//
// 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)
}
// 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
}
// TrashedEntry ist ein Eintrag im Papierkorb, so wie ihn die Papierkorb-Seite
// braucht: wer ihn wann gelöscht hat, ohne den ganzen Inhalt.
type TrashedEntry struct {
ID string
Slug string
Title string
Version int
DeletedAt time.Time
DeletedBy string // Name des Zugangs, der gelöscht hat
}
// EntryVersion ist eine abgelöste Fassung eines Eintrags — der Stand, der bei
// einem Schreibvorgang überschrieben wurde (siehe migrations/004).
type EntryVersion struct {
ID string
EntryID string
Version int // Fassungsnummer, die diese Zeile VOR der Ablösung trug
Title string
Content string
Tags []string
Author string
Source string
Curated bool
CreatedAt time.Time // Zeitpunkt der Ablösung, nicht der Entstehung
}
// Herkunft eines Eintrags.
const (
SourceHuman = "human"
SourceAgent = "agent"
)
// AuditEvent ist ein Eintrag im Nachvollziehbarkeitsprotokoll.
type AuditEvent struct {
ID string
Timestamp time.Time
ProjectID string
APIKeyID string
Actor string // Key-Name, "platform-admin" oder "unbekannt"
Action string // z.B. "entry_write", "key_created", "auth_failure"
EntrySlug string
Source string // "web", "mcp" oder "system"
IP string
Detail string
}
// ── Hilfsfunktionen für IDs und Zeit ─────────────────────────────────────────
// NewID erzeugt einen neuen Primärschlüssel.
//
// # Warum nicht die Datenbank zählen lassen?
//
// Ein AUTOINCREMENT wäre bequem, bringt aber drei Nachteile: Beim Umzug auf eine
// andere Datenbank müssen die Sequenzen nachgezogen werden, die IDs verraten die
// Anzahl der Datensätze, und man kennt die ID erst nach dem INSERT — was das
// Anlegen zusammenhängender Datensätze umständlich macht.
//
// # Aufbau
//
// Zeitanteil in Basis 36 + 8 zufällige Hex-Zeichen, z.B. "m1x2p3q4-a3f9c1d2".
// Der Zeitanteil steht vorne, damit die IDs in ihrer natürlichen Sortierung
// 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)
zufall := make([]byte, 4)
if _, err := rand.Read(zufall); 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 zeit + "-" + hex.EncodeToString(zufall)
}
// timeFormat ist das Format aller Zeitstempel in der Datenbank.
//
// RFC3339 in UTC, weil es sich als Text korrekt sortieren lässt: "2026-01-02T…"
// kommt alphabetisch vor "2026-01-03T…". Damit funktionieren ORDER BY und
// Bereichsabfragen ohne Datumsfunktionen der Datenbank — und die bleiben damit
// austauschbar.
const timeFormat = time.RFC3339Nano
// NowString gibt den aktuellen Zeitpunkt im Datenbankformat zurück.
func NowString() string {
return time.Now().UTC().Format(timeFormat)
}
// formatTime wandelt einen Zeitpunkt ins Datenbankformat.
// Der Nullzeitpunkt wird zum leeren String — so kommt die Anwendung ohne
// NULL-Behandlung aus, was den Go-Code deutlich schlanker hält.
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(timeFormat)
}
// parseTime wandelt einen Datenbankwert zurück in einen Zeitpunkt.
// Unlesbare oder leere Werte ergeben den Nullzeitpunkt statt eines Fehlers —
// ein kaputter Zeitstempel soll keine ganze Seite scheitern lassen.
func parseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(timeFormat, s)
if err != nil {
return time.Time{}
}
return t
}
// isUniqueViolation erkennt, ob ein Fehler von einer verletzten
// Eindeutigkeitsbedingung stammt.
//
// Leider gibt es dafür keinen einheitlichen Weg über verschiedene Datenbanken
// hinweg, ohne treiberspezifische Fehlertypen einzubinden. Die Textprüfung ist
// unschön, aber sie ist an genau einer Stelle gekapselt und deckt sowohl SQLite
// ("UNIQUE constraint failed") als auch PostgreSQL ("duplicate key value") ab.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "unique constraint") ||
strings.Contains(text, "duplicate key")
}