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
733
internal/store/themes.go
Normal file
733
internal/store/themes.go
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
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) }
|
||||
Loading…
Add table
Add a link
Reference in a new issue