// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer // globale und tenant-spezifische Konfigurationswerte mit Versionierung und // Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH // ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen. package cfgservice import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder // andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1). const GlobalScope = "global" var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden") type Value struct { Key string Scope string Value string Version int } type HistoryEntry struct { Key string Scope string Value string Version int } // Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den // aktuellen Stand (config_values) als auch einen Historieneintrag // (config_value_history) in derselben Transaktion — eine Aenderung ohne // Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2). type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1 // (Version 1 bei erstmaligem Setzen). func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) { if scope == "" { return Value{}, errors.New("cfgservice: scope darf nicht leer sein") } tx, err := s.pool.Begin(ctx) if err != nil { return Value{}, fmt.Errorf("transaktion starten: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var currentVersion int err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(¤tVersion) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Value{}, fmt.Errorf("aktuelle version lesen: %w", err) } newVersion := currentVersion + 1 if _, err := tx.Exec(ctx, ` INSERT INTO config_values (key, scope, value, version, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now() `, key, scope, value, newVersion); err != nil { return Value{}, fmt.Errorf("wert speichern: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO config_value_history (key, scope, value, version, changed_at) VALUES ($1, $2, $3, $4, now()) `, key, scope, value, newVersion); err != nil { return Value{}, fmt.Errorf("historie schreiben: %w", err) } if err := tx.Commit(ctx); err != nil { return Value{}, fmt.Errorf("transaktion committen: %w", err) } return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil } // Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die // Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit // Store rein CRUD bleibt. func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) { var v Value v.Key, v.Scope = key, scope err := s.pool.QueryRow(ctx, ` SELECT value, version FROM config_values WHERE key = $1 AND scope = $2 `, key, scope).Scan(&v.Value, &v.Version) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Value{}, ErrNotFound } return Value{}, fmt.Errorf("wert lesen: %w", err) } return v, nil } // History liefert die vollstaendige Versionshistorie eines (key, scope) in // aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3). func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT key, scope, value, version FROM config_value_history WHERE key = $1 AND scope = $2 ORDER BY version `, key, scope) if err != nil { return nil, fmt.Errorf("historie abfragen: %w", err) } defer rows.Close() var out []HistoryEntry for rows.Next() { var h HistoryEntry if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil { return nil, fmt.Errorf("historieneintrag lesen: %w", err) } out = append(out, h) } return out, rows.Err() }