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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

733
internal/store/themes.go Normal file
View 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) }