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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
250
internal/store/migrations.go
Normal file
250
internal/store/migrations.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue