// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit // Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion // des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server // + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket. package flag import ( "context" "errors" "fmt" "hash/fnv" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrNotFound = errors.New("flag: nicht gefunden") // Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon // getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung). type Flag struct { Key string Enabled bool RolloutPercentage int TargetTenantSlugs []string } // Store ist die Verwaltungsseite (Admin): Flags definieren/lesen. type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } func (s *Store) Set(ctx context.Context, f Flag) error { if f.TargetTenantSlugs == nil { f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array. } _, err := s.pool.Exec(ctx, ` INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at) VALUES ($1, $2, $3, $4, now()) ON CONFLICT (key) DO UPDATE SET enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now() `, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs) if err != nil { return fmt.Errorf("flag speichern: %w", err) } return nil } // List liefert alle definierten Flags (Admin-Uebersicht, siehe LIC-04) — // rein additiv, aendert nichts am bestehenden Verhalten von Set/Get. func (s *Store) List(ctx context.Context) ([]Flag, error) { rows, err := s.pool.Query(ctx, ` SELECT key, enabled, rollout_percentage, target_tenant_slugs FROM feature_flags ORDER BY key `) if err != nil { return nil, fmt.Errorf("flags auflisten: %w", err) } defer rows.Close() var out []Flag for rows.Next() { var f Flag if err := rows.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil { return nil, fmt.Errorf("flag-zeile lesen: %w", err) } out = append(out, f) } return out, rows.Err() } func (s *Store) Get(ctx context.Context, key string) (Flag, error) { var f Flag row := s.pool.QueryRow(ctx, ` SELECT key, enabled, rollout_percentage, target_tenant_slugs FROM feature_flags WHERE key = $1 `, key) if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Flag{}, ErrNotFound } return Flag{}, fmt.Errorf("flag lesen: %w", err) } return f, nil } // evaluate wendet die Strategien in fester Reihenfolge an: globaler // An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout. // Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default, // kein Feature wird versehentlich aktiv. func evaluate(f Flag, tenantSlug string) bool { if f.Enabled { return true } for _, target := range f.TargetTenantSlugs { if target == tenantSlug { return true } } if f.RolloutPercentage > 0 { h := fnv.New32a() _, _ = h.Write([]byte(f.Key + "|" + tenantSlug)) return int(h.Sum32()%100) < f.RolloutPercentage } return false }