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
733 lines
22 KiB
Go
733 lines
22 KiB
Go
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) }
|