package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/urpi/go-bananas/internal/domain" ) // themeColumns is the canonical SELECT list. Keeping it in one constant means // the scan helper and every query can never drift out of sync, which is the // classic source of "sql: expected N destination arguments" at runtime. const themeColumns = ` id, slug, name, description, status, default_appearance, tokens_json, meta_json, is_system, version, created_at, updated_at, created_by, updated_by` // themeRow is the flat representation used for scanning. type themeRow struct { domain.Theme tokensJSON string metaJSON string isSystem int } // List returns a page of themes matching the query. func (s *Store) List(ctx context.Context, q domain.ThemeQuery) (domain.ThemePage, error) { var ( where []string args []any ) if q.Search != "" { // LIKE with an escaped pattern. The ESCAPE clause stops a user-supplied // % or _ from turning the filter into a full scan of everything. where = append(where, `(slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`) pattern := "%" + escapeLike(q.Search) + "%" args = append(args, pattern, pattern, pattern) } if q.Status != "" { where = append(where, `status = ?`) args = append(args, string(q.Status)) } if q.System != nil { where = append(where, `is_system = ?`) args = append(args, boolToInt(*q.System)) } if q.Tag != "" { // Tags live inside the opaque meta document. A substring match on the // serialised form is sufficient: the value is a short JSON string and // the row count is small. where = append(where, `meta_json LIKE ? ESCAPE '\'`) args = append(args, "%\""+escapeLike(q.Tag)+"\"%") } clause := "" if len(where) > 0 { clause = " WHERE " + strings.Join(where, " AND ") } // The sort column is resolved through an allow-list rather than // interpolated, so caller input can never reach the SQL text. sortColumn := domain.SortFields["updated"] if mapped, ok := domain.SortFields[q.Sort]; ok { sortColumn = mapped } direction := "DESC" if strings.EqualFold(q.Order, "asc") { direction = "ASC" } limit := q.Limit if limit <= 0 || limit > 200 { limit = 50 } offset := q.Offset if offset < 0 { offset = 0 } total, err := s.countThemes(ctx, clause, args) if err != nil { return domain.ThemePage{}, err } // The token document is the bulk of the row. A listing only needs metadata, // so it is excluded here and fetched by Get. //nolint:gosec // G202: sortColumn is validated against a fixed whitelist by the // caller and direction is one of two literals, so neither can carry SQL. The // values are still bound for the LIMIT and OFFSET, which do support parameters. query := `SELECT` + themeColumns + ` FROM themes` + clause + fmt.Sprintf(" ORDER BY %s %s, slug ASC LIMIT ? OFFSET ?", sortColumn, direction) rows, err := s.db.QueryContext(ctx, query, append(append([]any{}, args...), limit, offset)...) if err != nil { return domain.ThemePage{}, fmt.Errorf("list themes: %w", translateError(err)) } defer rows.Close() page := domain.ThemePage{Items: []*domain.Theme{}, Limit: limit, Offset: offset} for rows.Next() { row, err := scanThemeRow(rows) if err != nil { return domain.ThemePage{}, err } theme, err := row.theme() if err != nil { return domain.ThemePage{}, err } page.Items = append(page.Items, theme) } if err := rows.Err(); err != nil { return domain.ThemePage{}, fmt.Errorf("iterate themes: %w", err) } page.Total = total return page, nil } // countThemes counts the rows matching a WHERE clause. func (s *Store) countThemes(ctx context.Context, clause string, args []any) (int, error) { var total int query := `SELECT COUNT(*) FROM themes` + clause if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { return 0, fmt.Errorf("count themes: %w", translateError(err)) } return total, nil } // ListRuntime returns the themes a runtime consumer may resolve. Drafts are // excluded; archived themes are included because a pinned consumer must keep // working after a theme is archived. func (s *Store) ListRuntime(ctx context.Context) ([]*domain.Theme, error) { rows, err := s.db.QueryContext(ctx, `SELECT`+themeColumns+ ` FROM themes WHERE status IN ('published', 'archived') ORDER BY name ASC`) if err != nil { return nil, fmt.Errorf("list runtime themes: %w", translateError(err)) } defer rows.Close() var out []*domain.Theme for rows.Next() { row, err := scanThemeRow(rows) if err != nil { return nil, err } theme, err := row.theme() if err != nil { return nil, err } out = append(out, theme) } return out, rows.Err() } // Get loads a theme by slug. func (s *Store) Get(ctx context.Context, slug string) (*domain.Theme, error) { row, err := scanThemeRow(s.db.QueryRowContext(ctx, `SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug)) if err != nil { return nil, wrapNotFound(err, "theme "+slug) } return row.theme() } // GetByID loads a theme by identifier. func (s *Store) GetByID(ctx context.Context, id string) (*domain.Theme, error) { row, err := scanThemeRow(s.db.QueryRowContext(ctx, `SELECT`+themeColumns+` FROM themes WHERE id = ?`, id)) if err != nil { return nil, wrapNotFound(err, "theme "+id) } return row.theme() } // Create inserts a new theme and its first history entry. func (s *Store) Create(ctx context.Context, theme *domain.Theme, message string) error { if err := domain.ValidateSlug(theme.Slug); err != nil { return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } if _, reserved := domain.ReservedSlugs[theme.Slug]; reserved { return fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, theme.Slug) } if theme.Name == "" { return fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid) } if theme.Status == "" { theme.Status = domain.StatusDraft } if !theme.Status.Valid() { return fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, theme.Status) } if theme.DefaultAppearance == "" { theme.DefaultAppearance = domain.AppearanceLight } if !theme.DefaultAppearance.Valid() { return fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, theme.DefaultAppearance) } if theme.Version < 1 { theme.Version = 1 } if theme.ID == "" { id, err := randomID() if err != nil { return err } theme.ID = "thm_" + id } tokensJSON, err := marshalJSON(theme.Tokens) if err != nil { return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } metaJSON, err := marshalJSON(theme.Meta) if err != nil { return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } now := s.now().UTC().Format(domain.TimeFormat) theme.CreatedAt = s.now().UTC() theme.UpdatedAt = theme.CreatedAt tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin create theme: %w", err) } defer tx.Rollback() if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil { return err } if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(message, theme, "created"), theme.CreatedBy, now); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit create theme: %w", err) } return nil } // UpdateFields carries the mutable attributes of a theme. A nil pointer leaves // the field unchanged, which is what lets the API accept partial edits without // a read-modify-write race in the handler. type UpdateFields struct { Slug *string Name *string Description *string Status *domain.ThemeStatus DefaultAppearance *domain.Appearance Tokens *domain.TokenDoc Meta *domain.ThemeMeta Message string Editor string } // Update applies a partial update and records a new version. func (s *Store) Update(ctx context.Context, slug string, fields UpdateFields) (*domain.Theme, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin update theme: %w", err) } defer tx.Rollback() current, err := scanThemeRow(tx.QueryRowContext(ctx, `SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug)) if err != nil { return nil, wrapNotFound(err, "theme "+slug) } theme, err := current.theme() if err != nil { return nil, err } if fields.Slug != nil && *fields.Slug != theme.Slug { if err := domain.ValidateSlug(*fields.Slug); err != nil { return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } if _, reserved := domain.ReservedSlugs[*fields.Slug]; reserved { return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, *fields.Slug) } theme.Slug = *fields.Slug } if fields.Name != nil { theme.Name = *fields.Name } if fields.Description != nil { theme.Description = *fields.Description } if fields.Status != nil { if !fields.Status.Valid() { return nil, fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, *fields.Status) } theme.Status = *fields.Status } if fields.DefaultAppearance != nil { if !fields.DefaultAppearance.Valid() { return nil, fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, *fields.DefaultAppearance) } theme.DefaultAppearance = *fields.DefaultAppearance } if fields.Tokens != nil { theme.Tokens = *fields.Tokens } if fields.Meta != nil { theme.Meta = *fields.Meta } if theme.Name == "" { return nil, fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid) } tokensJSON, err := marshalJSON(theme.Tokens) if err != nil { return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } metaJSON, err := marshalJSON(theme.Meta) if err != nil { return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } // Every write bumps the version: the version is the cache key for the // rendered CSS, so it must change whenever the rendered output could. theme.Version++ theme.UpdatedAt = s.now().UTC() theme.UpdatedBy = fields.Editor now := theme.UpdatedAt.Format(domain.TimeFormat) if _, err := tx.ExecContext(ctx, ` UPDATE themes SET slug = ?, name = ?, description = ?, status = ?, default_appearance = ?, tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ? WHERE id = ?`, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance), tokensJSON, metaJSON, theme.Version, now, fields.Editor, theme.ID, ); err != nil { return nil, fmt.Errorf("update theme: %w", translateError(err)) } if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(fields.Message, theme, "updated"), fields.Editor, now); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit update theme: %w", err) } return theme, nil } // Delete removes a theme and its history. Built-in themes cannot be deleted. func (s *Store) Delete(ctx context.Context, slug string) error { result, err := s.db.ExecContext(ctx, `DELETE FROM themes WHERE slug = ? AND is_system = 0`, slug) if err != nil { return fmt.Errorf("delete theme: %w", translateError(err)) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("delete theme: %w", err) } if affected == 0 { // Distinguish "does not exist" from "is built in" so the API can return // the right status code. var isSystem int err := s.db.QueryRowContext(ctx, `SELECT is_system FROM themes WHERE slug = ?`, slug).Scan(&isSystem) switch { case errors.Is(err, sql.ErrNoRows): return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug) case err != nil: return fmt.Errorf("delete theme: %w", err) case intToBool(isSystem): return fmt.Errorf("%w: theme %s is built in; duplicate it instead", domain.ErrImmutable, slug) default: return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug) } } return nil } // Duplicate copies a theme under a new slug. func (s *Store) Duplicate(ctx context.Context, slug, newSlug, editor string) (*domain.Theme, error) { source, err := s.Get(ctx, slug) if err != nil { return nil, err } if err := domain.ValidateSlug(newSlug); err != nil { return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error()) } if _, reserved := domain.ReservedSlugs[newSlug]; reserved { return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, newSlug) } copied := source.Clone() copied.ID = "" copied.Slug = newSlug copied.Name = source.Name + " (copy)" copied.Status = domain.StatusDraft // A duplicate starts at version 1: its history is its own, and copying the // version number would collide with nothing but would confuse the rollback // UI by implying revisions that never existed for this slug. copied.Version = 1 copied.System = false copied.CreatedBy = editor copied.UpdatedBy = editor // The copy is no longer a built-in even though it starts from one. if copied.Meta != nil { delete(copied.Meta, "builtIn") delete(copied.Meta, "seedVersion") } if err := s.Create(ctx, copied, "duplicated from "+slug); err != nil { return nil, err } return s.Get(ctx, newSlug) } // ---------------------------------------------------------------- history -- // Versions returns a theme's history, newest first. func (s *Store) Versions(ctx context.Context, slug string, limit int) ([]domain.ThemeVersion, error) { if _, err := s.Get(ctx, slug); err != nil { return nil, err } if limit <= 0 || limit > 200 { limit = 50 } rows, err := s.db.QueryContext(ctx, ` SELECT theme_id, version, slug, name, description, status, default_appearance, tokens_json, message, author, created_at FROM theme_versions WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) ORDER BY version DESC LIMIT ?`, slug, limit) if err != nil { return nil, fmt.Errorf("list versions: %w", translateError(err)) } defer rows.Close() out := make([]domain.ThemeVersion, 0, limit) for rows.Next() { var ( v domain.ThemeVersion status string appearance string tokensJSON string createdAt string ) if err := rows.Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status, &appearance, &tokensJSON, &v.Message, &v.Author, &createdAt); err != nil { return nil, fmt.Errorf("scan version: %w", err) } v.Status = domain.ThemeStatus(status) v.DefaultAppearance = domain.Appearance(appearance) if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil { return nil, fmt.Errorf("decode version tokens: %w", err) } if v.CreatedAt, err = parseTime(createdAt); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // Version loads one historical snapshot. func (s *Store) Version(ctx context.Context, slug string, version int64) (*domain.ThemeVersion, error) { var ( v domain.ThemeVersion status string appearance string tokensJSON string createdAt string ) err := s.db.QueryRowContext(ctx, ` SELECT theme_id, version, slug, name, description, status, default_appearance, tokens_json, message, author, created_at FROM theme_versions WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) AND version = ?`, slug, version, ).Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status, &appearance, &tokensJSON, &v.Message, &v.Author, &createdAt) if err != nil { return nil, wrapNotFound(err, fmt.Sprintf("version %d of theme %s", version, slug)) } v.Status = domain.ThemeStatus(status) v.DefaultAppearance = domain.Appearance(appearance) if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil { return nil, fmt.Errorf("decode version tokens: %w", err) } if v.CreatedAt, err = parseTime(createdAt); err != nil { return nil, err } return &v, nil } // Rollback restores a historical snapshot as a new version, which keeps history // append-only. Rewriting history would break the guarantee that a pinned // consumer can always resolve the version it was built against. func (s *Store) Rollback(ctx context.Context, slug string, version int64, editor string) (*domain.Theme, error) { snapshot, err := s.Version(ctx, slug, version) if err != nil { return nil, err } current, err := s.Get(ctx, slug) if err != nil { return nil, err } fields := UpdateFields{ Name: &snapshot.Name, Description: &snapshot.Description, Status: &snapshot.Status, DefaultAppearance: &snapshot.DefaultAppearance, Tokens: &snapshot.Tokens, Message: fmt.Sprintf("rolled back to version %d", version), Editor: editor, } if current.System { // Rolling a built-in theme back must not clear its built-in flag, but // the store's update path already leaves it untouched. fields.Editor = editor } return s.Update(ctx, slug, fields) } // Diff compares two versions of a theme. Only the token tree is compared; // metadata changes are reported as a boolean rather than field by field. type Diff struct { FromVersion int64 `json:"fromVersion"` ToVersion int64 `json:"toVersion"` Added []string `json:"added"` Removed []string `json:"removed"` Changed []TokenDiff `json:"changed"` MetaChanged bool `json:"metaChanged"` } // TokenDiff is one changed token. type TokenDiff struct { Path string `json:"path"` From string `json:"from"` To string `json:"to"` } // DiffVersions compares two snapshots of the same theme. func (s *Store) DiffVersions(ctx context.Context, slug string, from, to int64) (*Diff, error) { fromVersion, err := s.Version(ctx, slug, from) if err != nil { return nil, err } toVersion, err := s.Version(ctx, slug, to) if err != nil { return nil, err } // The three lists are allocated empty rather than left nil. A nil slice marshals // to JSON null, and the contract declares these as arrays -- so comparing two // revisions that differ in nothing, which is an ordinary outcome rather than an // edge case, would hand every client a null where it expects a list. The same // reason ListUsers normalises an absent roles column. result := &Diff{ FromVersion: from, ToVersion: to, Added: []string{}, Removed: []string{}, Changed: []TokenDiff{}, } fromTokens := flattenForDiff(fromVersion.Tokens) toTokens := flattenForDiff(toVersion.Tokens) for path, fromValue := range fromTokens { toValue, ok := toTokens[path] if !ok { result.Removed = append(result.Removed, path) continue } if fromValue != toValue { result.Changed = append(result.Changed, TokenDiff{Path: path, From: fromValue, To: toValue}) } } for path := range toTokens { if _, ok := fromTokens[path]; !ok { result.Added = append(result.Added, path) } } sortStrings(result.Added) sortStrings(result.Removed) sortSlice(result.Changed, func(a, b TokenDiff) bool { return a.Path < b.Path }) return result, nil } // flattenForDiff reduces a token document to a path to rendered value map. // // The walk is deliberately local to the store rather than delegating to the // token engine: a diff must compare exactly what was stored, including tokens // the current engine schema no longer recognises, so that rolling back across a // schema change still shows a faithful comparison. func flattenForDiff(doc domain.TokenDoc) map[string]string { flat := make(map[string]string, 128) walkForDiff(doc, "", flat) return flat } // walkForDiff recurses through a token group. func walkForDiff(node map[string]any, prefix string, out map[string]string) { for key, raw := range node { if strings.HasPrefix(key, "$") { continue } child, ok := raw.(map[string]any) if !ok { continue } path := key if prefix != "" { path = prefix + "." + key } if value, hasValue := child["$value"]; hasValue { out[path] = stringifyTokenValue(value) continue } walkForDiff(child, path, out) } } // stringifyTokenValue renders a token value for comparison. func stringifyTokenValue(value any) string { switch v := value.(type) { case string: return v case float64: // JSON numbers decode as float64; format them without an exponent so // that 1 and 1.0 do not show up as a spurious change. return strconv.FormatFloat(v, 'f', -1, 64) case bool: return strconv.FormatBool(v) case []any: parts := make([]string, len(v)) for i, p := range v { parts[i] = stringifyTokenValue(p) } return strings.Join(parts, ", ") default: return fmt.Sprint(v) } } // ---------------------------------------------------------------- helpers -- // scanThemeRow reads one theme row from a scanner. func scanThemeRow(scanner interface{ Scan(...any) error }) (*themeRow, error) { var ( row themeRow status string appearance string createdAt string updatedAt string ) err := scanner.Scan( &row.ID, &row.Slug, &row.Name, &row.Description, &status, &appearance, &row.tokensJSON, &row.metaJSON, &row.isSystem, &row.Version, &createdAt, &updatedAt, &row.CreatedBy, &row.UpdatedBy, ) if err != nil { return nil, err } row.Status = domain.ThemeStatus(status) row.DefaultAppearance = domain.Appearance(appearance) row.System = intToBool(row.isSystem) row.CreatedAt, err = parseTime(createdAt) if err != nil { return nil, err } row.UpdatedAt, err = parseTime(updatedAt) if err != nil { return nil, err } return &row, nil } // theme materialises a scanned row into a domain theme. func (r *themeRow) theme() (*domain.Theme, error) { tokens := domain.TokenDoc{} if r.tokensJSON != "" { if err := json.Unmarshal([]byte(r.tokensJSON), &tokens); err != nil { return nil, fmt.Errorf("decode tokens for theme %s: %w", r.Slug, err) } } meta := domain.ThemeMeta{} if r.metaJSON != "" { if err := json.Unmarshal([]byte(r.metaJSON), &meta); err != nil { return nil, fmt.Errorf("decode meta for theme %s: %w", r.Slug, err) } } out := r.Theme out.Tokens = tokens out.Meta = meta return &out, nil } // parseTime converts a stored timestamp back into a time.Time. func parseTime(raw string) (time.Time, error) { if raw == "" { return time.Time{}, nil } parsed, err := time.Parse(domain.TimeFormat, raw) if err != nil { return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err) } return parsed, nil } // escapeLike neutralises LIKE wildcards in user input. func escapeLike(s string) string { replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) return replacer.Replace(s) } // defaultMessage returns the supplied message or a generated one. func defaultMessage(message string, theme *domain.Theme, verb string) string { if trimmed := strings.TrimSpace(message); trimmed != "" { return trimmed } return verb + " " + theme.Name } // Now is exposed for the audit log, which shares the store's clock. func (s *Store) timestamp() string { return s.now().UTC().Format(domain.TimeFormat) }