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
250 lines
7.9 KiB
Go
250 lines
7.9 KiB
Go
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
|
|
}
|