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

186 lines
6.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
}
// 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")
}