package store import ( "context" "database/sql" "embed" "errors" "fmt" ) // controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank // (siehe migrations_control/sqlite/001_init.sql und AUSBAUSTUFEN.md 4.4), je // Datenbank in einem eigenen Unterordner wie bei migrationsFS. // //go:embed migrations_control/sqlite/*.sql migrations_control/postgres/*.sql var controlMigrationsFS embed.FS // ControlStore verwaltet die zentrale Kontroll-Datenbank: die Liste der // Workspaces (Abteilungen) und den Weg von einem Key-Hash zu seinem Workspace. // // Alles andere — Projekte, Einträge, Keys im Volltext, Protokoll — lebt in der // jeweiligen Workspace-Datei (Store, siehe store.go). Ein eigener Typ statt // eines zweiten Satzes Methoden auf Store, damit nie aus Versehen eine // Workspace-Abfrage gegen die falsche Datei läuft. type ControlStore struct { lesen pool schreiben pool // haken sind die zusätzlichen Schritte, die der Speicher beim Anlegen und // Umbenennen eines Workspace braucht. Bei SQLite gibt es keine (nil): // Eine Workspace-Datei entsteht beim ersten Öffnen von selbst. Bei // PostgreSQL wird geprüft, ob die Verbindungen reichen, und das Schema // samt lesbarem Namen angelegt (siehe speicher_postgres.go). haken workspaceHaken } // workspaceHaken wird vom Speicher gesetzt, siehe ControlStore.haken. type workspaceHaken interface { // vorDemAnlegen prüft, ob ein weiterer Workspace möglich ist. anzahl ist // die Zahl der Workspaces MIT dem neuen. vorDemAnlegen(ctx context.Context, anzahl int) error // nachDemBenennen läuft nach dem Anlegen und nach jedem Umbenennen. nachDemBenennen(ctx context.Context, workspaceID, name string) error } // OpenControl öffnet die Kontroll-Datenbank als SQLite-Datei und bringt ihr // Schema auf den aktuellen Stand. Für PostgreSQL siehe Speicher.OpenControl. func OpenControl(path string) (*ControlStore, error) { lesen, schreiben, err := openSQLite(path, controlMigrationsFS, "migrations_control") if err != nil { return nil, err } return &ControlStore{lesen: lesen, schreiben: schreiben}, nil } // Verbindungen liefert die Zahlen beider Pools (siehe Verbindungen). func (c *ControlStore) Verbindungen() Verbindungen { return Verbindungen{Lesen: c.lesen.db.Stats(), Schreiben: c.schreiben.db.Stats()} } // Close schließt beide Verbindungspools. func (c *ControlStore) Close() error { fehlerLesen := c.lesen.Close() if err := c.schreiben.Close(); err != nil { return err } return fehlerLesen } // Ping prüft mit einer echten Abfrage, ob die Kontroll-Datenbank antwortet. // Für /ready (siehe internal/web/server.go). Bei SQLite ist das die Datei, // bei PostgreSQL der Datenbankserver — und damit auch der Weg dorthin. func (c *ControlStore) Ping(ctx context.Context) error { var eins int return c.lesen.QueryRowContext(ctx, `SELECT 1`).Scan(&eins) } // ── Workspaces ─────────────────────────────────────────────────────────────── // EnsureDefaultWorkspace legt beim allerersten Start eines Deployments den // ersten Workspace an und gibt ihn zurück. Gibt es bereits einen oder mehrere, // wird der älteste verwendet. // // Gedacht für den Fall, dass noch keine Workspace-Verwaltung existiert (WP8): // ein frischer Server soll ohne manuellen Zwischenschritt lauffähig sein. // Sobald es eine Oberfläche zum Anlegen weiterer Workspaces gibt, verliert // diese Funktion an Bedeutung, bleibt aber für den ersten Start richtig. func (c *ControlStore) EnsureDefaultWorkspace(ctx context.Context, name string) (Workspace, error) { var w Workspace var createdAt string err := c.lesen.QueryRowContext(ctx, `SELECT id, name, created_at FROM workspaces ORDER BY created_at LIMIT 1`, ).Scan(&w.ID, &w.Name, &createdAt) if err == nil { w.CreatedAt = parseTime(createdAt) return w, nil } if !errors.Is(err, sql.ErrNoRows) { return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err) } return c.CreateWorkspace(ctx, name) } // CreateWorkspace legt einen neuen Workspace an. func (c *ControlStore) CreateWorkspace(ctx context.Context, name string) (Workspace, error) { if c.haken != nil { var bisher int if err := c.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM workspaces`).Scan(&bisher); err != nil { return Workspace{}, fmt.Errorf("Workspaces konnten nicht gezählt werden: %w", err) } if err := c.haken.vorDemAnlegen(ctx, bisher+1); err != nil { return Workspace{}, err } } w := Workspace{ID: NewID(), Name: name} jetzt := NowString() if _, err := c.schreiben.ExecContext(ctx, `INSERT INTO workspaces (id, name, created_at) VALUES (?, ?, ?)`, w.ID, w.Name, jetzt, ); err != nil { return Workspace{}, fmt.Errorf("Workspace konnte nicht angelegt werden: %w", err) } if c.haken != nil { if err := c.haken.nachDemBenennen(ctx, w.ID, w.Name); err != nil { return Workspace{}, err } } w.CreatedAt = parseTime(jetzt) return w, nil } // ListWorkspaces gibt alle Workspaces zurück, älteste zuerst. func (c *ControlStore) ListWorkspaces(ctx context.Context) ([]Workspace, error) { rows, err := c.lesen.QueryContext(ctx, `SELECT id, name, created_at FROM workspaces ORDER BY created_at`, ) if err != nil { return nil, fmt.Errorf("Workspaces konnten nicht gelesen werden: %w", err) } defer rows.Close() var ws []Workspace for rows.Next() { var w Workspace var createdAt string if err := rows.Scan(&w.ID, &w.Name, &createdAt); err != nil { return nil, err } w.CreatedAt = parseTime(createdAt) ws = append(ws, w) } return ws, rows.Err() } // WorkspaceByID liest einen Workspace anhand seiner ID. func (c *ControlStore) WorkspaceByID(ctx context.Context, id string) (Workspace, error) { var w Workspace var createdAt string err := c.lesen.QueryRowContext(ctx, `SELECT id, name, created_at FROM workspaces WHERE id = ?`, id, ).Scan(&w.ID, &w.Name, &createdAt) if errors.Is(err, sql.ErrNoRows) { return Workspace{}, ErrNotFound } if err != nil { return Workspace{}, fmt.Errorf("Workspace konnte nicht gelesen werden: %w", err) } w.CreatedAt = parseTime(createdAt) return w, nil } // UpdateWorkspace benennt einen Workspace um. func (c *ControlStore) UpdateWorkspace(ctx context.Context, id, name string) error { res, err := c.schreiben.ExecContext(ctx, `UPDATE workspaces SET name = ? WHERE id = ?`, name, id, ) if err != nil { return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) } betroffen, err := res.RowsAffected() if err != nil { return fmt.Errorf("Workspace konnte nicht umbenannt werden: %w", err) } if betroffen == 0 { return ErrNotFound } if c.haken != nil { return c.haken.nachDemBenennen(ctx, id, name) } return nil } // DeleteWorkspace entfernt einen Workspace aus der Kontroll-Datenbank. // // Löscht nur die Zeile hier — die zugehörigen `key_index`-Einträge fallen // über den Fremdschlüssel (ON DELETE CASCADE) automatisch mit weg. Die // eigentliche Workspace-Datei mit allen Projekten und Einträgen bleibt davon // unberührt; sie zu entfernen ist Sache von workspace.Manager.Delete — erst // wenn beides passiert ist, ist der Workspace wirklich weg (siehe // handleWorkspaceDelete in internal/web/handlers_workspaces.go). func (c *ControlStore) DeleteWorkspace(ctx context.Context, id string) error { res, err := c.schreiben.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) if err != nil { return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) } betroffen, err := res.RowsAffected() if err != nil { return fmt.Errorf("Workspace konnte nicht gelöscht werden: %w", err) } if betroffen == 0 { return ErrNotFound } return nil } // ── Key-Routing ────────────────────────────────────────────────────────────── // IndexKey trägt einen Key-Hash in den Routing-Index ein. Aufzurufen im // selben Zug wie Store.CreateAPIKey auf der jeweiligen Workspace-Datei — ohne // diesen Eintrag kann der Key künftig nicht mehr seinem Workspace zugeordnet // werden, bevor dessen Datei bekannt ist. func (c *ControlStore) IndexKey(ctx context.Context, hash, workspaceID string) error { _, err := c.schreiben.ExecContext(ctx, `INSERT INTO key_index (hash, workspace_id, created_at) VALUES (?, ?, ?)`, hash, workspaceID, NowString(), ) if err != nil { return fmt.Errorf("Key konnte nicht im Routing-Index vermerkt werden: %w", err) } return nil } // RemoveKeyIndex entfernt einen Key-Hash aus dem Routing-Index. Aufzurufen im // selben Zug wie Store.RevokeAPIKey. func (c *ControlStore) RemoveKeyIndex(ctx context.Context, hash string) error { _, err := c.schreiben.ExecContext(ctx, `DELETE FROM key_index WHERE hash = ?`, hash, ) if err != nil { return fmt.Errorf("Key konnte nicht aus dem Routing-Index entfernt werden: %w", err) } return nil } // ResolveKeyWorkspace schlägt nach, zu welchem Workspace ein Key-Hash gehört. // // Das ist der erste Schritt jeder Key-Auflösung, sobald mehrere // Workspace-Dateien gleichzeitig offen sein können (siehe AUSBAUSTUFEN.md 4.4, // WP4/WP5): erst hier lässt sich feststellen, welche Datei überhaupt // angefasst werden muss, bevor Store.ResolveKeyHash dort wie gewohnt läuft. func (c *ControlStore) ResolveKeyWorkspace(ctx context.Context, hash string) (workspaceID string, err error) { err = c.lesen.QueryRowContext(ctx, `SELECT workspace_id FROM key_index WHERE hash = ?`, hash, ).Scan(&workspaceID) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("Workspace konnte nicht zum Key aufgelöst werden: %w", err) } return workspaceID, nil }