package store import ( "context" "database/sql" "embed" "errors" "fmt" ) // controlMigrationsFS enthält das Schema der zentralen Kontroll-Datenbank // (siehe migrations_control/001_init.sql und AUSBAUSTUFEN.md 4.4). // //go:embed migrations_control/*.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 *sql.DB schreiben *sql.DB } // OpenControl öffnet die Kontroll-Datenbank und bringt ihr Schema auf den // aktuellen Stand. func OpenControl(path string) (*ControlStore, error) { lesen, schreiben, err := openPool(path, controlMigrationsFS, "migrations_control") if err != nil { return nil, err } return &ControlStore{lesen: lesen, schreiben: schreiben}, nil } // 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 } // ── 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) { 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) } 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 } 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 }