munin/internal/store/models.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

218 lines
7.1 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()
}
// 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
}
// 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")
}