initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

View file

@ -0,0 +1,250 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"embed"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"strings"
"sync"
"github.com/urpi/go-bananas/internal/domain"
)
// migrationsFS holds the schema that ships inside the binary, so a fresh
// container needs no migration job and no schema files mounted beside it.
//
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migration is one applied schema change.
type Migration struct {
Name string
// Statements is the number of statements the file contains, reported for
// logging only.
Statements int
}
// migrationLock serialises migration across goroutines within a process. SQLite
// already serialises writers across processes, so no cross-process lock is
// needed, but two replicas starting simultaneously would otherwise both log the
// same applied migration.
var migrationLock sync.Mutex
// migrate applies every embedded migration that has not yet been recorded.
//
// Migrations are idempotent by construction: each file uses CREATE ... IF NOT
// EXISTS, so re-running is harmless. The applied set is tracked in a table so
// that a future migration which is not idempotent can still be introduced
// safely.
func (s *Store) migrate(ctx context.Context) error {
migrationLock.Lock()
defer migrationLock.Unlock()
if _, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
applied, err := s.appliedMigrations(ctx)
if err != nil {
return err
}
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
if _, done := applied[entry.Name()]; done {
continue
}
body, err := fs.ReadFile(migrationsFS, "migrations/"+entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
entry.Name(), s.now().UTC().Format(domain.TimeFormat),
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
}
}
return nil
}
// appliedMigrations returns the set of recorded migration names.
func (s *Store) appliedMigrations(ctx context.Context) (map[string]struct{}, error) {
rows, err := s.db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
applied := make(map[string]struct{})
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("scan schema_migrations: %w", err)
}
applied[name] = struct{}{}
}
return applied, rows.Err()
}
// Seed reconciles the built-in themes with what is stored.
//
// The rules are:
// - a missing built-in is inserted;
// - a built-in still at its seed version is refreshed, so an upgrade picks up
// palette and schema fixes;
// - a built-in an operator has edited (version above the seed) is left alone;
// - a non-built-in theme is never touched.
//
// It returns how many themes were inserted and how many were refreshed.
func (s *Store) Seed(ctx context.Context, builtins []*domain.Theme) (inserted, refreshed int, err error) {
if len(builtins) == 0 {
return 0, 0, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("begin seed: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := s.now().UTC().Format(domain.TimeFormat)
for _, theme := range builtins {
tokensJSON, err := marshalJSON(theme.Tokens)
if err != nil {
return 0, 0, fmt.Errorf("marshal tokens for %s: %w", theme.Slug, err)
}
metaJSON, err := marshalJSON(theme.Meta)
if err != nil {
return 0, 0, fmt.Errorf("marshal meta for %s: %w", theme.Slug, err)
}
var existingVersion int64
err = tx.QueryRowContext(ctx,
`SELECT version FROM themes WHERE id = ? OR slug = ?`, theme.ID, theme.Slug,
).Scan(&existingVersion)
switch {
case errors.Is(err, sql.ErrNoRows):
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
return 0, 0, fmt.Errorf("insert built-in theme %s: %w", theme.Slug, err)
}
if err := insertVersion(ctx, tx, theme, tokensJSON, "built-in seed", "system", now); err != nil {
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
}
inserted++
case err != nil:
return 0, 0, fmt.Errorf("look up built-in theme %s: %w", theme.Slug, err)
default:
// Refresh only when the stored copy predates this seed. An equal
// version means the theme is already current; a higher version means
// an operator has edited it, and their work wins either way.
if existingVersion >= theme.Version {
continue
}
if err := refreshTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
return 0, 0, fmt.Errorf("refresh built-in theme %s: %w", theme.Slug, err)
}
if err := insertVersion(ctx, tx, theme, tokensJSON, "refreshed by Go Bananas!! upgrade", "system", now); err != nil {
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
}
refreshed++
}
}
if err = tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("commit seed: %w", err)
}
return inserted, refreshed, nil
}
// insertTheme writes a new theme row.
func insertTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO themes (
id, slug, name, description, status, default_appearance,
tokens_json, meta_json, is_system, version,
created_at, updated_at, created_by, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
theme.ID, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
tokensJSON, metaJSON, boolToInt(theme.System), theme.Version,
now, now, theme.CreatedBy, theme.UpdatedBy,
)
return translateError(err)
}
// refreshTheme overwrites an untouched built-in theme in place.
func refreshTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE themes SET
name = ?, description = ?, status = ?, default_appearance = ?,
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
WHERE slug = ?`,
theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
tokensJSON, metaJSON, theme.Version, now, "system",
theme.Slug,
)
return translateError(err)
}
// insertVersion appends an immutable snapshot to a theme's history.
func insertVersion(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, message, author, now string) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO theme_versions (
theme_id, version, slug, name, description, status,
default_appearance, tokens_json, message, author, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
theme.ID, theme.Version, theme.Slug, theme.Name, theme.Description, string(theme.Status),
string(theme.DefaultAppearance), tokensJSON, message, author, now,
)
return translateError(err)
}
// randomID returns a URL-safe random identifier.
func randomID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}