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 }