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
62
internal/store/backup.go
Normal file
62
internal/store/backup.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Snapshot writes a consistent copy of the database to dest.
|
||||
//
|
||||
// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database
|
||||
// while a write is in flight yields a torn copy: the header can say one thing and
|
||||
// the pages say another, and the result may open successfully while being
|
||||
// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result
|
||||
// is consistent even while the service is serving traffic, and it does not block
|
||||
// writers for the length of the copy.
|
||||
//
|
||||
// Two further properties come from the statement itself rather than from code
|
||||
// here: it refuses to overwrite an existing file, so a stale snapshot can never be
|
||||
// mistaken for a fresh one, and it writes to a temporary name and renames on
|
||||
// success, so a snapshot that fails halfway is not left looking complete.
|
||||
//
|
||||
// The connection must not be read-only: VACUUM INTO needs to read the schema and
|
||||
// pages through a normal connection even though it only ever writes to the new
|
||||
// file.
|
||||
func (s *Store) Snapshot(ctx context.Context, dest string) error {
|
||||
trimmed := strings.TrimSpace(dest)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if dir := filepath.Dir(trimmed); dir != "" && dir != "." {
|
||||
// Created by the process rather than by SQLite, which has no mkdir. The
|
||||
// snapshot directory normally already exists (the Helm chart's subPath mount
|
||||
// creates it), so this is a convenience for a manual invocation.
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create snapshot directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VACUUM INTO takes an SQL string literal rather than a bound parameter on some
|
||||
// SQLite builds, so the path is escaped into one here. Doubling the quote is
|
||||
// the documented escape, and rejecting an embedded NUL keeps the literal
|
||||
// well-formed regardless.
|
||||
if strings.ContainsRune(trimmed, 0) {
|
||||
return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid)
|
||||
}
|
||||
literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'"
|
||||
|
||||
//nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a
|
||||
// bound parameter on some SQLite builds. The literal is escaped above by doubling
|
||||
// any single quote, which is the documented escape, and NUL bytes are rejected so
|
||||
// the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers
|
||||
// a path containing a quote.
|
||||
if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil {
|
||||
return fmt.Errorf("write snapshot to %s: %w", trimmed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
185
internal/store/backup_test.go
Normal file
185
internal/store/backup_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/store"
|
||||
)
|
||||
|
||||
// openTemp opens a file-backed store in a temporary directory, which is the only
|
||||
// kind worth snapshotting: an in-memory database has nothing to preserve.
|
||||
func openTemp(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "live.db")
|
||||
st, err := store.Open(context.Background(), store.Options{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// TestSnapshotProducesAReadableDatabase is the property the backup exists for: the
|
||||
// file that comes out must be a database, not merely a file with the right size.
|
||||
func TestSnapshotProducesAReadableDatabase(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "snapshots", "go-bananas.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat snapshot: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("snapshot is empty")
|
||||
}
|
||||
|
||||
// Reopening is the check that matters. A truncated or torn copy often fails
|
||||
// here rather than at the point of use, which is exactly the failure a restore
|
||||
// would hit at the worst possible moment.
|
||||
reopened, err := store.Open(context.Background(), store.Options{Path: dest, ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot is not a readable database: %v", err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
|
||||
// The schema must have come across, not just the bytes: an empty database with
|
||||
// no tables would also open cleanly.
|
||||
var count int
|
||||
if err := reopened.DB().QueryRowContext(context.Background(),
|
||||
"SELECT count(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil {
|
||||
t.Fatalf("query snapshot schema: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Error("snapshot has no tables; the schema did not come across")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRefusesToOverwrite guards the failure this feature is easiest to get
|
||||
// wrong: a snapshot that silently overwrites the previous good one turns a failed
|
||||
// run into two data-loss events instead of one.
|
||||
func TestSnapshotRefusesToOverwrite(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("first snapshot: %v", err)
|
||||
}
|
||||
first, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err == nil {
|
||||
t.Fatal("expected the second snapshot to be refused")
|
||||
}
|
||||
|
||||
second, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !first.ModTime().Equal(second.ModTime()) {
|
||||
t.Error("the existing snapshot was modified by the refused write")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCreatesTheParentDirectory covers the manual-invocation path, where
|
||||
// the operator names a directory that does not exist yet.
|
||||
func TestSnapshotCreatesTheParentDirectory(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
base := t.TempDir()
|
||||
dest := filepath.Join(base, "a", "b", "c", "snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("snapshot not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRejectsEmptyDestination checks the argument validation rather than
|
||||
// letting SQLite interpret an empty filename as the current directory.
|
||||
func TestSnapshotRejectsEmptyDestination(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
if err := st.Snapshot(context.Background(), " "); err == nil {
|
||||
t.Error("expected an error for a blank destination")
|
||||
} else if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("got %v, want an invalid-input error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotEscapesQuotesInThePath guards the SQL-literal construction. A path
|
||||
// containing a single quote must not be able to terminate the literal and append
|
||||
// statements of its own.
|
||||
func TestSnapshotEscapesQuotesInThePath(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "it's a snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("snapshot not written to the quoted path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotOfAnInMemoryDatabaseIsRefused documents that the store-level guard
|
||||
// exists, rather than leaving it to the command line.
|
||||
func TestIsPersistentPath(t *testing.T) {
|
||||
for _, path := range []string{":memory:", "file::memory:?cache=shared", "file:x?mode=memory"} {
|
||||
if store.IsPersistentPath(path) {
|
||||
t.Errorf("%q should be reported as ephemeral", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/srv/data/go-bananas.db", "./data/db.sqlite"} {
|
||||
if !store.IsPersistentPath(path) {
|
||||
t.Errorf("%q should be reported as persistent", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotUnderLoadStaysConsistent is a smoke test for the reason VACUUM INTO
|
||||
// is used rather than a file copy: writing while the snapshot is taken must not
|
||||
// corrupt the result.
|
||||
func TestSnapshotUnderLoadStaysConsistent(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := st.DB().ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)`,
|
||||
"concurrent.key", strconv.Itoa(i), time.Now().UTC().Format(time.RFC3339Nano), "test"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "under-load.db")
|
||||
if err := st.Snapshot(ctx, dest); err != nil {
|
||||
t.Fatalf("Snapshot during writes: %v", err)
|
||||
}
|
||||
<-done
|
||||
|
||||
reopened, err := store.Open(ctx, store.Options{Path: dest, ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot taken during writes is unreadable: %v", err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
if err := reopened.DB().PingContext(ctx); err != nil {
|
||||
t.Errorf("snapshot failed an integrity check: %v", err)
|
||||
}
|
||||
}
|
||||
250
internal/store/migrations.go
Normal file
250
internal/store/migrations.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// migrationsFS holds the schema that ships inside the binary, so a fresh
|
||||
// container needs no migration job and no schema files mounted beside it.
|
||||
//
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Migration is one applied schema change.
|
||||
type Migration struct {
|
||||
Name string
|
||||
// Statements is the number of statements the file contains, reported for
|
||||
// logging only.
|
||||
Statements int
|
||||
}
|
||||
|
||||
// migrationLock serialises migration across goroutines within a process. SQLite
|
||||
// already serialises writers across processes, so no cross-process lock is
|
||||
// needed, but two replicas starting simultaneously would otherwise both log the
|
||||
// same applied migration.
|
||||
var migrationLock sync.Mutex
|
||||
|
||||
// migrate applies every embedded migration that has not yet been recorded.
|
||||
//
|
||||
// Migrations are idempotent by construction: each file uses CREATE ... IF NOT
|
||||
// EXISTS, so re-running is harmless. The applied set is tracked in a table so
|
||||
// that a future migration which is not idempotent can still be introduced
|
||||
// safely.
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
migrationLock.Lock()
|
||||
defer migrationLock.Unlock()
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := s.appliedMigrations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
if _, done := applied[entry.Name()]; done {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := fs.ReadFile(migrationsFS, "migrations/"+entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
|
||||
entry.Name(), s.now().UTC().Format(domain.TimeFormat),
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appliedMigrations returns the set of recorded migration names.
|
||||
func (s *Store) appliedMigrations(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("scan schema_migrations: %w", err)
|
||||
}
|
||||
applied[name] = struct{}{}
|
||||
}
|
||||
return applied, rows.Err()
|
||||
}
|
||||
|
||||
// Seed reconciles the built-in themes with what is stored.
|
||||
//
|
||||
// The rules are:
|
||||
// - a missing built-in is inserted;
|
||||
// - a built-in still at its seed version is refreshed, so an upgrade picks up
|
||||
// palette and schema fixes;
|
||||
// - a built-in an operator has edited (version above the seed) is left alone;
|
||||
// - a non-built-in theme is never touched.
|
||||
//
|
||||
// It returns how many themes were inserted and how many were refreshed.
|
||||
func (s *Store) Seed(ctx context.Context, builtins []*domain.Theme) (inserted, refreshed int, err error) {
|
||||
if len(builtins) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("begin seed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
now := s.now().UTC().Format(domain.TimeFormat)
|
||||
|
||||
for _, theme := range builtins {
|
||||
tokensJSON, err := marshalJSON(theme.Tokens)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("marshal tokens for %s: %w", theme.Slug, err)
|
||||
}
|
||||
metaJSON, err := marshalJSON(theme.Meta)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("marshal meta for %s: %w", theme.Slug, err)
|
||||
}
|
||||
|
||||
var existingVersion int64
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT version FROM themes WHERE id = ? OR slug = ?`, theme.ID, theme.Slug,
|
||||
).Scan(&existingVersion)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in theme %s: %w", theme.Slug, err)
|
||||
}
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, "built-in seed", "system", now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
|
||||
}
|
||||
inserted++
|
||||
|
||||
case err != nil:
|
||||
return 0, 0, fmt.Errorf("look up built-in theme %s: %w", theme.Slug, err)
|
||||
|
||||
default:
|
||||
// Refresh only when the stored copy predates this seed. An equal
|
||||
// version means the theme is already current; a higher version means
|
||||
// an operator has edited it, and their work wins either way.
|
||||
if existingVersion >= theme.Version {
|
||||
continue
|
||||
}
|
||||
if err := refreshTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
|
||||
return 0, 0, fmt.Errorf("refresh built-in theme %s: %w", theme.Slug, err)
|
||||
}
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, "refreshed by Go Bananas!! upgrade", "system", now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit seed: %w", err)
|
||||
}
|
||||
return inserted, refreshed, nil
|
||||
}
|
||||
|
||||
// insertTheme writes a new theme row.
|
||||
func insertTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO themes (
|
||||
id, slug, name, description, status, default_appearance,
|
||||
tokens_json, meta_json, is_system, version,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
theme.ID, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
|
||||
tokensJSON, metaJSON, boolToInt(theme.System), theme.Version,
|
||||
now, now, theme.CreatedBy, theme.UpdatedBy,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// refreshTheme overwrites an untouched built-in theme in place.
|
||||
func refreshTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE themes SET
|
||||
name = ?, description = ?, status = ?, default_appearance = ?,
|
||||
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
|
||||
WHERE slug = ?`,
|
||||
theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
|
||||
tokensJSON, metaJSON, theme.Version, now, "system",
|
||||
theme.Slug,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// insertVersion appends an immutable snapshot to a theme's history.
|
||||
func insertVersion(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, message, author, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO theme_versions (
|
||||
theme_id, version, slug, name, description, status,
|
||||
default_appearance, tokens_json, message, author, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
theme.ID, theme.Version, theme.Slug, theme.Name, theme.Description, string(theme.Status),
|
||||
string(theme.DefaultAppearance), tokensJSON, message, author, now,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// randomID returns a URL-safe random identifier.
|
||||
func randomID() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
144
internal/store/migrations/0001_init.sql
Normal file
144
internal/store/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
-- go-bananas schema.
|
||||
--
|
||||
-- Design notes
|
||||
-- ------------
|
||||
-- * Timestamps are TEXT in RFC 3339 (see domain.TimeFormat). SQLite has no
|
||||
-- native date type, and storing a sortable string removes every date-parsing
|
||||
-- ambiguity from ORDER BY and range filters.
|
||||
-- * JSON columns are TEXT holding a JSON document. The token document is read
|
||||
-- and written as a whole value, never queried into, so a JSON column is the
|
||||
-- correct shape: it avoids a join table whose only purpose would be to
|
||||
-- reassemble a tree the application always wants whole.
|
||||
-- * Every table a consumer might read carries an explicit covering index.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------- themes ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'published', 'archived')),
|
||||
default_appearance TEXT NOT NULL DEFAULT 'light'
|
||||
CHECK (default_appearance IN ('light', 'dark')),
|
||||
tokens_json TEXT NOT NULL,
|
||||
meta_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- The runtime API resolves by slug on every page load, so this index is the
|
||||
-- single most important one in the schema.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_themes_slug ON themes (slug);
|
||||
|
||||
-- Listing is always ordered by recency within a status filter.
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_status_updated
|
||||
ON themes (status, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_updated ON themes (updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_created ON themes (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_name ON themes (name COLLATE NOCASE);
|
||||
|
||||
-- Free-text search over slug, name and description. SQLite's LIKE with a
|
||||
-- leading wildcard cannot use an index, so this stays a scan; the table is
|
||||
-- small by construction (themes are hand-authored, not machine-generated) and
|
||||
-- the scan is served from the page cache.
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_search
|
||||
ON themes (name COLLATE NOCASE, slug, description);
|
||||
|
||||
-- ---------------------------------------------------------- theme_versions --
|
||||
|
||||
CREATE TABLE IF NOT EXISTS theme_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
theme_id TEXT NOT NULL REFERENCES themes (id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
default_appearance TEXT NOT NULL,
|
||||
tokens_json TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (theme_id, version)
|
||||
);
|
||||
|
||||
-- Rollback resolves a theme's history newest-first.
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_theme
|
||||
ON theme_versions (theme_id, version DESC);
|
||||
|
||||
-- ---------------------------------------------------------------- api_keys --
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
-- prefix is the non-secret leading segment shown in the UI so a key can be
|
||||
-- identified after its secret has been forgotten.
|
||||
prefix TEXT NOT NULL,
|
||||
-- secret_hash is a SHA-256 of the full key. The plaintext is never stored,
|
||||
-- so a database disclosure does not yield usable credentials.
|
||||
secret_hash TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
identity TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- Lookup by secret hash happens on every machine request.
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (secret_hash);
|
||||
-- The UI lists live keys newest-first and hides revoked ones by default.
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_live ON api_keys (revoked_at, created_at DESC);
|
||||
|
||||
-- ----------------------------------------------------------------- users ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
subject TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
roles TEXT NOT NULL DEFAULT '[]',
|
||||
method TEXT NOT NULL DEFAULT '',
|
||||
first_seen TEXT NOT NULL,
|
||||
last_login TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
-- ------------------------------------------------------------- audit_log ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
actor_email TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL DEFAULT '',
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
detail_json TEXT NOT NULL DEFAULT '{}',
|
||||
remote_ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor, at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log (entity_type, entity_id, at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action, at DESC);
|
||||
|
||||
-- -------------------------------------------------------------- settings ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
19
internal/store/migrations/0002_drop_api_keys.sql
Normal file
19
internal/store/migrations/0002_drop_api_keys.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Drop the machine-credential table.
|
||||
--
|
||||
-- API keys were removed: the runtime API is public by design and the admin API is
|
||||
-- for people, so there is no machine caller left to authenticate. The table is
|
||||
-- dropped rather than left behind, because an unused table holding SHA-256 digests
|
||||
-- of credentials that no longer authenticate anything is a liability with no
|
||||
-- upside, and its presence in the schema implies a capability that no longer exists.
|
||||
--
|
||||
-- The rows go with it. That is destructive by definition, which is why this is a
|
||||
-- migration rather than a code change: it happens once, in order, and is recorded in
|
||||
-- schema_migrations so a restore from an older backup is visible rather than silent.
|
||||
--
|
||||
-- A backup taken before this migration still contains the table and its rows. The
|
||||
-- credentials in it do not work against any version of this service, so restoring
|
||||
-- one is harmless.
|
||||
|
||||
DROP INDEX IF EXISTS idx_api_keys_hash;
|
||||
DROP INDEX IF EXISTS idx_api_keys_live;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
26
internal/store/migrations/0002_user_preferences.sql
Normal file
26
internal/store/migrations/0002_user_preferences.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Per-user interface preferences.
|
||||
--
|
||||
-- These live in their own table rather than as columns on users, because users is
|
||||
-- a projection of the identity provider's claims: UpsertUser rewrites it on every
|
||||
-- login. A row appears the first time somebody saves a preference and is keyed by
|
||||
-- the same subject, so the choice survives re-login and follows the account to
|
||||
-- another browser.
|
||||
--
|
||||
-- An empty selected_theme means the person has not chosen a theme, which the UI
|
||||
-- renders as its own default rather than as a stored value.
|
||||
--
|
||||
-- There is deliberately no foreign key to users. That table is a projection of the
|
||||
-- identity provider's claims and can be rebuilt from a backup, so a cascade from it
|
||||
-- would silently discard people's choices, and requiring the row to exist would make
|
||||
-- saving a preference depend on projection state that has nothing to do with the
|
||||
-- preference. A row whose subject never logs in again is inert and costs nothing.
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
subject TEXT NOT NULL PRIMARY KEY,
|
||||
selected_theme TEXT NOT NULL DEFAULT '',
|
||||
selected_appearance TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Lets an operator answer "who is using this theme?" without scanning every row.
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_theme
|
||||
ON user_preferences (selected_theme);
|
||||
369
internal/store/repositories.go
Normal file
369
internal/store/repositories.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------- users --
|
||||
|
||||
// UpsertUser records a successful login. Roles are overwritten from the
|
||||
// identity provider on every login rather than merged, so that a user who is
|
||||
// removed from a group actually loses access.
|
||||
func (s *Store) UpsertUser(ctx context.Context, identity *domain.Identity) error {
|
||||
if identity == nil || identity.Subject == "" {
|
||||
return fmt.Errorf("%w: identity subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
roles, err := json.Marshal(identity.Roles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode roles: %w", err)
|
||||
}
|
||||
|
||||
now := s.timestamp()
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO users (subject, email, display_name, roles, method, first_seen, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(subject) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
display_name = excluded.display_name,
|
||||
roles = excluded.roles,
|
||||
method = excluded.method,
|
||||
last_login = excluded.last_login`,
|
||||
identity.Subject, identity.Email, identity.Name, string(roles), identity.Method, now, now,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// User is a stored account projection.
|
||||
type User struct {
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastLogin time.Time `json:"lastLogin"`
|
||||
}
|
||||
|
||||
// ListUsers returns every known account, most recently seen first.
|
||||
func (s *Store) ListUsers(ctx context.Context, limit int) ([]User, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT subject, email, display_name, roles, method, first_seen, last_login
|
||||
FROM users ORDER BY last_login DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]User, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
u User
|
||||
rolesJSON string
|
||||
firstSeen string
|
||||
lastLogin string
|
||||
)
|
||||
if err := rows.Scan(&u.Subject, &u.Email, &u.Name, &rolesJSON, &u.Method, &firstSeen, &lastLogin); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
// Deliberately degraded, not accidentally ignored: a malformed roles blob
|
||||
// yields a user with no roles, which the authorisation layer treats as
|
||||
// read-only. Failing the whole listing because one row has a corrupt column
|
||||
// would be worse than degrading that single user, and it would mean one bad
|
||||
// row hides every other user from the admin.
|
||||
//
|
||||
//nolint:errcheck // see above
|
||||
_ = json.Unmarshal([]byte(rolesJSON), &u.Roles)
|
||||
if u.Roles == nil {
|
||||
u.Roles = []string{}
|
||||
}
|
||||
if u.FirstSeen, err = parseTime(firstSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.LastLogin, err = parseTime(lastLogin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UserCount returns the number of known accounts.
|
||||
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settings --
|
||||
|
||||
// Setting returns a setting value, or fallback when it is absent.
|
||||
func (s *Store) Setting(ctx context.Context, key, fallback string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fallback, nil
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("read setting %s: %w", key, err)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetSetting writes a setting.
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value, actor string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
||||
key, value, s.timestamp(), actor)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// AllSettings returns every setting.
|
||||
func (s *Store) AllSettings(ctx context.Context) (map[string]domain.Setting, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value, updated_at, updated_by FROM settings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list settings: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]domain.Setting)
|
||||
for rows.Next() {
|
||||
var (
|
||||
setting domain.Setting
|
||||
updatedAt string
|
||||
)
|
||||
if err := rows.Scan(&setting.Key, &setting.Value, &updatedAt, &setting.UpdatedBy); err != nil {
|
||||
return nil, fmt.Errorf("scan setting: %w", err)
|
||||
}
|
||||
if setting.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[setting.Key] = setting
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- audit --
|
||||
|
||||
// AuditFilter narrows the activity log.
|
||||
type AuditFilter struct {
|
||||
Actor string
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AppendAudit writes one audit entry. Failures are returned but callers on the
|
||||
// request path generally log and continue: losing an audit row is bad, failing
|
||||
// a user's publish because of it is worse.
|
||||
func (s *Store) AppendAudit(ctx context.Context, entry *domain.AuditEntry) error {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("%w: audit entry must not be nil", domain.ErrInvalid)
|
||||
}
|
||||
if entry.Action == "" {
|
||||
return fmt.Errorf("%w: audit entry requires an action", domain.ErrInvalid)
|
||||
}
|
||||
if entry.At.IsZero() {
|
||||
entry.At = s.now().UTC()
|
||||
}
|
||||
|
||||
detail, err := json.Marshal(entry.Detail)
|
||||
if err != nil {
|
||||
detail = []byte("{}")
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
entry.At.UTC().Format(domain.TimeFormat), entry.Actor, entry.ActorEmail,
|
||||
string(entry.Action), entry.EntityType, entry.EntityID, entry.Summary,
|
||||
string(detail), entry.RemoteIP,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// ListAudit returns audit entries newest first.
|
||||
func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.AuditEntry, int, error) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
if filter.Actor != "" {
|
||||
where = append(where, `actor = ?`)
|
||||
args = append(args, filter.Actor)
|
||||
}
|
||||
if filter.Action != "" {
|
||||
where = append(where, `action = ?`)
|
||||
args = append(args, filter.Action)
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
where = append(where, `entity_type = ?`)
|
||||
args = append(args, filter.EntityType)
|
||||
}
|
||||
if filter.EntityID != "" {
|
||||
where = append(where, `entity_id = ?`)
|
||||
args = append(args, filter.EntityID)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
where = append(where, `at >= ?`)
|
||||
args = append(args, filter.Since.UTC().Format(domain.TimeFormat))
|
||||
}
|
||||
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`+clause, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count audit entries: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip
|
||||
FROM audit_log`+clause+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
append(append([]any{}, args...), limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list audit entries: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]domain.AuditEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry domain.AuditEntry
|
||||
action string
|
||||
at string
|
||||
detailJSON string
|
||||
)
|
||||
if err := rows.Scan(&entry.ID, &at, &entry.Actor, &entry.ActorEmail, &action,
|
||||
&entry.EntityType, &entry.EntityID, &entry.Summary, &detailJSON, &entry.RemoteIP); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
|
||||
}
|
||||
entry.Action = domain.AuditAction(action)
|
||||
if entry.At, err = parseTime(at); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if detailJSON != "" {
|
||||
// Same reasoning as the roles column: an audit entry whose detail cannot
|
||||
// be decoded is still worth returning, because the point of the audit log
|
||||
// is that the record of the action exists.
|
||||
//
|
||||
//nolint:errcheck // see above
|
||||
_ = json.Unmarshal([]byte(detailJSON), &entry.Detail)
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// AuditRetention returns the age of the oldest audit entry, used by the
|
||||
// retention job.
|
||||
func (s *Store) AuditRetention(ctx context.Context) (time.Time, error) {
|
||||
var oldest sql.NullString
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM audit_log`).Scan(&oldest); err != nil {
|
||||
return time.Time{}, fmt.Errorf("audit retention: %w", err)
|
||||
}
|
||||
if !oldest.Valid {
|
||||
return s.now().UTC(), nil
|
||||
}
|
||||
return parseTime(oldest.String)
|
||||
}
|
||||
|
||||
// PurgeAudit removes entries older than the cutoff and returns the count.
|
||||
func (s *Store) PurgeAudit(ctx context.Context, before time.Time) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM audit_log WHERE at < ?`, before.UTC().Format(domain.TimeFormat))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge audit: %w", translateError(err))
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// Stats summarises the instance for the admin dashboard.
|
||||
type Stats struct {
|
||||
Themes int `json:"themes"`
|
||||
ThemesByStatus map[string]int `json:"themesByStatus"`
|
||||
Users int `json:"users"`
|
||||
AuditEntries int `json:"auditEntries"`
|
||||
DatabaseSize int64 `json:"databaseSizeBytes"`
|
||||
VersionsRetaind int `json:"versionsRetained"`
|
||||
}
|
||||
|
||||
// Stats gathers dashboard counters in a single pass.
|
||||
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||||
stats := Stats{ThemesByStatus: map[string]int{}}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM themes GROUP BY status`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
status string
|
||||
count int
|
||||
)
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
stats.ThemesByStatus[status] = count
|
||||
stats.Themes += count
|
||||
}
|
||||
// rows.Next() returning false means either no rows or an error. Checking Err
|
||||
// distinguishes them, and without it a failure part way through the result set
|
||||
// reports success with silently incomplete counters.
|
||||
if err := rows.Err(); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
|
||||
for _, query := range []struct {
|
||||
sql string
|
||||
dest *int
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, &stats.Users},
|
||||
{`SELECT COUNT(*) FROM audit_log`, &stats.AuditEntries},
|
||||
{`SELECT COUNT(*) FROM theme_versions`, &stats.VersionsRetaind},
|
||||
} {
|
||||
if err := s.db.QueryRowContext(ctx, query.sql).Scan(query.dest); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// page_count * page_size is the on-disk size of the main database file. It
|
||||
// is a cheap approximation, which is all a dashboard needs.
|
||||
var pageCount, pageSize int64
|
||||
if err := s.db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err == nil {
|
||||
if err := s.db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err == nil {
|
||||
stats.DatabaseSize = pageCount * pageSize
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
13
internal/store/sort.go
Normal file
13
internal/store/sort.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// sortStrings orders a string slice in place.
|
||||
func sortStrings(values []string) { sort.Strings(values) }
|
||||
|
||||
// sortSlice orders a slice in place using the supplied comparison.
|
||||
func sortSlice[T any](values []T, less func(a, b T) bool) {
|
||||
sort.Slice(values, func(i, j int) bool { return less(values[i], values[j]) })
|
||||
}
|
||||
307
internal/store/store.go
Normal file
307
internal/store/store.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// Package store is the persistence layer. It owns the SQLite connection, the
|
||||
// schema and every query the application runs.
|
||||
//
|
||||
// # Why SQLite
|
||||
//
|
||||
// Go Bananas!! stores themes: a few thousand rows of JSON documents that are read
|
||||
// constantly and written rarely. That workload is exactly what an embedded
|
||||
// engine is good at, and a client/server database would add a network hop, a
|
||||
// connection pool, a second container, credentials to rotate and a schema
|
||||
// migration process to a service whose entire dataset comfortably fits in a few
|
||||
// megabytes.
|
||||
//
|
||||
// The configuration that makes this genuinely fast rather than merely adequate:
|
||||
//
|
||||
// - WAL journalling, so readers never block the writer and vice versa.
|
||||
// - synchronous=NORMAL, which in WAL mode is durable across process crashes
|
||||
// and only risks the last transactions on an OS crash. For a design-token
|
||||
// store that is the correct trade: a lost publish is re-doable, an outage
|
||||
// is not.
|
||||
// - A deliberately small connection pool. SQLite serialises writers, so many
|
||||
// connections buy contention, not throughput.
|
||||
// - Prepared statements cached by database/sql, so the hot runtime query is
|
||||
// compiled once at start-up.
|
||||
// - Rendered themes memoised in memory, so the steady-state runtime request
|
||||
// performs no database access at all.
|
||||
//
|
||||
// The engine is modernc.org/sqlite, a pure-Go translation of SQLite. It needs no
|
||||
// cgo, so the release image builds from a static base with no libc and the
|
||||
// cross-compiled binaries are genuinely portable.
|
||||
//
|
||||
// # Concurrency
|
||||
//
|
||||
// A Store is safe for concurrent use. The connection pool handles concurrency;
|
||||
// write transactions are serialised by SQLite's own locking, which at this write
|
||||
// volume is not a bottleneck. Callers must not hold a *sql.Tx across an HTTP
|
||||
// boundary; use the callback forms so that a panic rolls the transaction back.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// marshalJSON encodes a value for a TEXT column. The store always writes JSON
|
||||
// through this helper so that a value which cannot be marshalled fails at the
|
||||
// boundary rather than producing a corrupt row.
|
||||
func marshalJSON(v any) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// boolToInt renders a bool for a SQLite INTEGER column.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// intToBool is the inverse of boolToInt.
|
||||
func intToBool(v int) bool { return v != 0 }
|
||||
|
||||
// Store is the persistence facade.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
// inMemory reports whether the store is backed by an ephemeral database.
|
||||
inMemory bool
|
||||
}
|
||||
|
||||
// Options configures a Store.
|
||||
type Options struct {
|
||||
// Path is the database file, or ":memory:" for an ephemeral database.
|
||||
Path string
|
||||
// BusyTimeout is how long a blocked writer waits before failing.
|
||||
BusyTimeout time.Duration
|
||||
// MaxConns bounds the connection pool.
|
||||
MaxConns int
|
||||
// ReadOnly opens the database without write access, for replicas.
|
||||
ReadOnly bool
|
||||
// Now overrides the clock, for deterministic tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// sentinelMapping translates the SQLite driver's typed errors into the domain
|
||||
// sentinels the HTTP layer matches on. Doing this in one place keeps every
|
||||
// repository from re-implementing driver-specific string matching.
|
||||
var sentinelMapping = map[string]error{
|
||||
"constraint failed: UNIQUE constraint failed": domain.ErrConflict,
|
||||
"constraint failed: CHECK constraint failed": domain.ErrInvalid,
|
||||
"constraint failed: NOT NULL constraint failed": domain.ErrInvalid,
|
||||
"constraint failed: FOREIGN KEY constraint failed": domain.ErrConflict,
|
||||
}
|
||||
|
||||
// Open connects to the database, applies the schema and verifies it.
|
||||
func Open(ctx context.Context, opts Options) (*Store, error) {
|
||||
if opts.Path == "" {
|
||||
return nil, fmt.Errorf("%w: database path must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if opts.BusyTimeout <= 0 {
|
||||
opts.BusyTimeout = 5 * time.Second
|
||||
}
|
||||
if opts.MaxConns <= 0 {
|
||||
opts.MaxConns = 8
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
|
||||
inMemory := isMemoryPath(opts.Path)
|
||||
dsn := buildDSN(opts.Path, opts.BusyTimeout, opts.ReadOnly)
|
||||
|
||||
if !inMemory && !opts.ReadOnly {
|
||||
if dir := filepath.Dir(opts.Path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// A small pool is deliberate. See the package comment.
|
||||
db.SetMaxOpenConns(opts.MaxConns)
|
||||
db.SetMaxIdleConns(opts.MaxConns)
|
||||
db.SetConnMaxIdleTime(time.Minute)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, now: opts.Now, inMemory: inMemory}
|
||||
if !opts.ReadOnly {
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// buildDSN assembles the SQLite connection string.
|
||||
//
|
||||
// modernc.org/sqlite takes its PRAGMAs as query parameters. Using the _pragma
|
||||
// form rather than executing PRAGMA statements after opening is what makes the
|
||||
// settings apply to every pooled connection instead of only the first.
|
||||
func buildDSN(path string, busyTimeout time.Duration, readOnly bool) string {
|
||||
params := url.Values{}
|
||||
|
||||
// WAL lets readers and the writer proceed concurrently.
|
||||
if !readOnly && !isMemoryPath(path) {
|
||||
params.Add("_pragma", "journal_mode(WAL)")
|
||||
}
|
||||
// NORMAL is durable across application crashes in WAL mode. See the package
|
||||
// comment for why that is the right trade here.
|
||||
params.Add("_pragma", "synchronous(NORMAL)")
|
||||
params.Add("_pragma", "foreign_keys(ON)")
|
||||
params.Add("_pragma", "busy_timeout("+fmt.Sprint(busyTimeout.Milliseconds())+")")
|
||||
// A negative cache size is a KiB budget; 16 MiB comfortably holds the whole
|
||||
// dataset in memory.
|
||||
params.Add("_pragma", "cache_size(-16000)")
|
||||
// temp_store=MEMORY keeps sort and group-by spill out of the filesystem.
|
||||
params.Add("_pragma", "temp_store(MEMORY)")
|
||||
// mmap lets SQLite read the file through the page cache directly, which
|
||||
// removes a copy on the hot read path.
|
||||
params.Add("_pragma", "mmap_size(268435456)")
|
||||
if readOnly {
|
||||
params.Add("mode", "ro")
|
||||
}
|
||||
|
||||
base := path
|
||||
if isMemoryPath(path) {
|
||||
// A shared cache is required for an in-memory database to be visible
|
||||
// across the pooled connections, otherwise each connection would get its
|
||||
// own empty database.
|
||||
switch {
|
||||
case strings.Contains(path, "?"):
|
||||
// The caller supplied a full URI with its own query.
|
||||
base = path
|
||||
case nameFromPath(path) != "":
|
||||
base = "file:" + nameFromPath(path) + "?mode=memory&cache=shared"
|
||||
default:
|
||||
// A bare ":memory:" carries no name. Naming it uniquely keeps two
|
||||
// stores in one process isolated from each other, which is what a test
|
||||
// creating a second store expects; leaving it unnamed would hand every
|
||||
// caller the same database and let one test's rows appear in another's.
|
||||
base = "file:" + ephemeralMemoryName() + "?mode=memory&cache=shared"
|
||||
}
|
||||
}
|
||||
|
||||
// The separator depends on whether the base already carries a query. Joining
|
||||
// with "?" unconditionally produces "cache=shared?_pragma=...", which SQLite
|
||||
// reads as a cache mode literally named "shared?_pragma=..." and rejects.
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return base + sep + params.Encode()
|
||||
}
|
||||
|
||||
// ephemeralMemoryName returns a random name for an unnamed in-memory database.
|
||||
func ephemeralMemoryName() string {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// A fixed name is wrong but not fatal: the store still opens and the only
|
||||
// consequence is that two concurrent unnamed stores would share a database.
|
||||
// Failing to open over an entropy hiccup would be worse than that.
|
||||
return "memory-default"
|
||||
}
|
||||
return "memory-" + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// IsPersistentPath reports whether a database path holds data that outlives the
|
||||
// process. An ephemeral database cannot be snapshotted, and a caller that tries is
|
||||
// better served by being told so than by producing an empty file.
|
||||
func IsPersistentPath(path string) bool { return !isMemoryPath(path) }
|
||||
|
||||
// isMemoryPath reports whether the path refers to an ephemeral database.
|
||||
func isMemoryPath(path string) bool {
|
||||
return path == ":memory:" || strings.HasPrefix(path, "file::memory:") || strings.Contains(path, "mode=memory")
|
||||
}
|
||||
|
||||
// nameFromPath derives a stable shared-cache identifier from a path, so that
|
||||
// two in-memory stores created with the same name are genuinely the same
|
||||
// database and two created with different names are isolated.
|
||||
func nameFromPath(path string) string {
|
||||
name := strings.TrimPrefix(path, ":memory:")
|
||||
name = strings.TrimPrefix(name, "file:")
|
||||
if idx := strings.IndexByte(name, '?'); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
name = strings.Trim(name, "/")
|
||||
if name == "" {
|
||||
return "go-bananas-memory"
|
||||
}
|
||||
// Names are hashed rather than escaped so that any path can be used safely.
|
||||
return "go-bananas-" + fmt.Sprintf("%x", len(name)) + "-" + strings.ReplaceAll(name, "/", "_")
|
||||
}
|
||||
|
||||
// Close releases the database.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB exposes the underlying handle for health checks and tests.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Ping verifies connectivity, used by the readiness probe.
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
// InMemory reports whether the store is ephemeral.
|
||||
func (s *Store) InMemory() bool { return s.inMemory }
|
||||
|
||||
// Now returns the store's clock.
|
||||
func (s *Store) Now() time.Time { return s.now() }
|
||||
|
||||
// translateError maps a driver error onto a domain sentinel.
|
||||
func translateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
|
||||
}
|
||||
msg := err.Error()
|
||||
for fragment, sentinel := range sentinelMapping {
|
||||
if strings.Contains(msg, fragment) {
|
||||
return fmt.Errorf("%w: %s", sentinel, msg)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapNotFound attaches context to an ErrNotFound result.
|
||||
func wrapNotFound(err error, what string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, what)
|
||||
}
|
||||
return translateError(err)
|
||||
}
|
||||
717
internal/store/store_test.go
Normal file
717
internal/store/store_test.go
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/seed"
|
||||
)
|
||||
|
||||
// newTestStore returns a store backed by a temporary on-disk database, so that
|
||||
// the test exercises the same file-based code path as production including WAL.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := Open(context.Background(), Options{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
// sampleTheme returns a minimal valid theme for store tests.
|
||||
func sampleTheme(slug string) *domain.Theme {
|
||||
return &domain.Theme{
|
||||
Slug: slug,
|
||||
Name: "Sample " + slug,
|
||||
Description: "a theme used in tests",
|
||||
Status: domain.StatusDraft,
|
||||
DefaultAppearance: domain.AppearanceLight,
|
||||
Tokens: domain.TokenDoc{
|
||||
"color": map[string]any{
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{"$type": "color", "$value": "#ffffff"},
|
||||
"primary": map[string]any{"$type": "color", "$value": "#4f46e5"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Meta: domain.ThemeMeta{"tags": []string{"test"}},
|
||||
CreatedBy: "tester",
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAppliesSchema(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, table := range []string{"themes", "theme_versions", "users", "audit_log", "settings", "schema_migrations"} {
|
||||
var name string
|
||||
err := store.DB().QueryRowContext(t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("table %s missing: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetTheme(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
theme := sampleTheme("acme")
|
||||
if err := store.Create(ctx, theme, "initial"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if loaded.ID == "" {
|
||||
t.Error("expected an assigned id")
|
||||
}
|
||||
if loaded.Version != 1 {
|
||||
t.Errorf("version = %d, want 1", loaded.Version)
|
||||
}
|
||||
if loaded.Tokens["color"] == nil {
|
||||
t.Error("tokens did not round-trip")
|
||||
}
|
||||
if loaded.System {
|
||||
t.Error("a user-created theme must not be flagged as built in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsDuplicateSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
err := store.Create(ctx, sampleTheme("acme"), "")
|
||||
if !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("duplicate slug error = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsReservedSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
err := store.Create(ctx, sampleTheme("api"), "")
|
||||
if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("reserved slug error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsInvalidStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
theme := sampleTheme("acme")
|
||||
theme.Status = "sideways"
|
||||
if err := store.Create(ctx, theme, ""); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("invalid status error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBumpsVersionAndRecordsHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), "created"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
name := "Renamed"
|
||||
updated, err := store.Update(ctx, "acme", UpdateFields{Name: &name, Message: "renamed"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Errorf("version = %d, want 2", updated.Version)
|
||||
}
|
||||
if updated.Name != name {
|
||||
t.Errorf("name = %q, want %q", updated.Name, name)
|
||||
}
|
||||
|
||||
versions, err := store.Versions(ctx, "acme", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("got %d versions, want 2", len(versions))
|
||||
}
|
||||
// Newest first.
|
||||
if versions[0].Version != 2 || versions[0].Message != "renamed" {
|
||||
t.Errorf("unexpected newest version: %+v", versions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialUpdateLeavesOtherFieldsAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
description := "updated description"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Description: &description}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if loaded.Name != "Sample acme" {
|
||||
t.Errorf("name changed to %q by a description-only update", loaded.Name)
|
||||
}
|
||||
if loaded.Description != description {
|
||||
t.Errorf("description = %q, want %q", loaded.Description, description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameChangesSlugAndKeepsHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
newSlug := "acme-v2"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Slug: &newSlug}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("old slug should be gone, got %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "acme-v2"); err != nil {
|
||||
t.Errorf("new slug should resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRefusesBuiltInThemes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
err := store.Delete(ctx, "midnight")
|
||||
if !errors.Is(err, domain.ErrImmutable) {
|
||||
t.Fatalf("deleting a built-in theme = %v, want ErrImmutable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesThemeAndCascadesHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "acme"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("theme should be gone, got %v", err)
|
||||
}
|
||||
|
||||
var versions int
|
||||
if err := store.DB().QueryRowContext(t.Context(),
|
||||
`SELECT COUNT(*) FROM theme_versions WHERE slug = ?`, "acme").Scan(&versions); err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if versions != 0 {
|
||||
t.Errorf("history rows survived deletion: %d", versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUnknownThemeReportsNotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.Delete(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateProducesIndependentDraft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
copied, err := store.Duplicate(ctx, "acme", "acme-copy", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("Duplicate: %v", err)
|
||||
}
|
||||
|
||||
if copied.Status != domain.StatusDraft {
|
||||
t.Errorf("status = %q, want draft", copied.Status)
|
||||
}
|
||||
if copied.Version != 1 {
|
||||
t.Errorf("version = %d, want 1", copied.Version)
|
||||
}
|
||||
if copied.System {
|
||||
t.Error("a duplicate must not remain flagged as built in")
|
||||
}
|
||||
|
||||
// Mutating the copy must not touch the original.
|
||||
name := "Changed"
|
||||
if _, err := store.Update(ctx, "acme-copy", UpdateFields{Name: &name}); err != nil {
|
||||
t.Fatalf("Update copy: %v", err)
|
||||
}
|
||||
original, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get original: %v", err)
|
||||
}
|
||||
if original.Name == "Changed" {
|
||||
t.Error("mutating the duplicate changed the original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAppendsRatherThanRewritesHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
name := "Second"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Name: &name}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
restored, err := store.Rollback(ctx, "acme", 1, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if restored.Name != "Sample acme" {
|
||||
t.Errorf("name = %q, want the version 1 value", restored.Name)
|
||||
}
|
||||
if restored.Version != 3 {
|
||||
t.Errorf("version = %d, want 3; a rollback must append", restored.Version)
|
||||
}
|
||||
|
||||
versions, err := store.Versions(ctx, "acme", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 3 {
|
||||
t.Errorf("got %d versions, want 3 (history must be append-only)", len(versions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
tokens := sampleTheme("acme").Tokens
|
||||
tokens["color"].(map[string]any)["semantic"].(map[string]any)["primary"] =
|
||||
map[string]any{"$type": "color", "$value": "#ff0000"}
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "recolour"}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffVersions: %v", err)
|
||||
}
|
||||
if len(diff.Changed) != 1 {
|
||||
t.Fatalf("got %d changed tokens, want 1: %+v", len(diff.Changed), diff.Changed)
|
||||
}
|
||||
change := diff.Changed[0]
|
||||
if change.Path != "color.semantic.primary" {
|
||||
t.Errorf("path = %q", change.Path)
|
||||
}
|
||||
if change.From != "#4f46e5" || change.To != "#ff0000" {
|
||||
t.Errorf("change = %s -> %s", change.From, change.To)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffVersionsOfIdenticalRevisionsMarshalAsLists pins the wire contract for a
|
||||
// diff with nothing in it.
|
||||
//
|
||||
// Two revisions with no differences is an ordinary outcome, not an edge case: a save
|
||||
// that only changed a description, or an operator comparing the wrong pair. The JSON
|
||||
// still has to carry three lists, because that is what the API promises and what a
|
||||
// typed client reads the length of.
|
||||
func TestDiffVersionsOfIdenticalRevisionsMarshalAsLists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// A second save of the same token document, so the versions differ only in their
|
||||
// message and the diff has nothing to report.
|
||||
tokens := sampleTheme("acme").Tokens
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "no-op save"}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffVersions: %v", err)
|
||||
}
|
||||
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil {
|
||||
t.Fatalf("empty lists must be non-nil: %+v", diff)
|
||||
}
|
||||
if len(diff.Added)+len(diff.Removed)+len(diff.Changed) != 0 {
|
||||
t.Errorf("identical revisions should diff to nothing: %+v", diff)
|
||||
}
|
||||
|
||||
// The assertion that matters to a client: null would break every consumer that
|
||||
// reads a length, and JSON is the contract, not the Go struct.
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
for _, field := range []string{`"added":[]`, `"removed":[]`, `"changed":[]`} {
|
||||
if !strings.Contains(string(encoded), field) {
|
||||
t.Errorf("diff JSON missing %s: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{`"added":null`, `"removed":null`, `"changed":null`} {
|
||||
if strings.Contains(string(encoded), field) {
|
||||
t.Errorf("diff JSON has %s, which a typed client cannot read a length from: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersAndSorts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, slug := range []string{"alpha", "beta", "gamma"} {
|
||||
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("Create %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
if _, err := store.Update(ctx, "beta", UpdateFields{Status: &published}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
page, err := store.List(ctx, domain.ThemeQuery{Status: domain.StatusPublished})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].Slug != "beta" {
|
||||
t.Errorf("published filter returned %+v", page)
|
||||
}
|
||||
|
||||
page, err = store.List(ctx, domain.ThemeQuery{Search: "alph"})
|
||||
if err != nil {
|
||||
t.Fatalf("List search: %v", err)
|
||||
}
|
||||
if page.Total != 1 || page.Items[0].Slug != "alpha" {
|
||||
t.Errorf("search returned %+v", page)
|
||||
}
|
||||
|
||||
page, err = store.List(ctx, domain.ThemeQuery{Sort: "name", Order: "asc"})
|
||||
if err != nil {
|
||||
t.Fatalf("List sorted: %v", err)
|
||||
}
|
||||
if len(page.Items) != 3 || page.Items[0].Slug != "alpha" {
|
||||
t.Errorf("sorted page = %+v", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIgnoresUnknownSortColumn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// An unknown sort must fall back to the default rather than reaching SQL.
|
||||
if _, err := store.List(ctx, domain.ThemeQuery{Sort: "slug; DROP TABLE themes"}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSearchTreatsWildcardsLiterally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
for _, slug := range []string{"alpha", "beta"} {
|
||||
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := store.List(ctx, domain.ThemeQuery{Search: "%"})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if page.Total != 0 {
|
||||
t.Errorf("a literal %% matched %d themes; wildcards must be escaped", page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRuntimeExcludesDrafts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
if err := store.Create(ctx, sampleTheme("live-one"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := store.Update(ctx, "live-one", UpdateFields{Status: &published}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
themes, err := store.ListRuntime(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRuntime: %v", err)
|
||||
}
|
||||
if len(themes) != 1 || themes[0].Slug != "live-one" {
|
||||
t.Errorf("ListRuntime returned %d themes, want only the published one", len(themes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
inserted, _, err := store.Seed(ctx, seed.Themes())
|
||||
if err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
if inserted == 0 {
|
||||
t.Fatal("first seed inserted nothing")
|
||||
}
|
||||
|
||||
// Declared here rather than alongside the first call, so the first call's
|
||||
// "refreshed" is not an assignment that is immediately overwritten.
|
||||
var refreshed int
|
||||
inserted, refreshed, err = store.Seed(ctx, seed.Themes())
|
||||
if err != nil {
|
||||
t.Fatalf("second Seed: %v", err)
|
||||
}
|
||||
if inserted != 0 || refreshed != 0 {
|
||||
t.Errorf("second seed inserted=%d refreshed=%d, want 0/0", inserted, refreshed)
|
||||
}
|
||||
|
||||
for _, slug := range seed.Slugs() {
|
||||
theme, err := store.Get(ctx, slug)
|
||||
if err != nil {
|
||||
t.Errorf("built-in %s missing: %v", slug, err)
|
||||
continue
|
||||
}
|
||||
if !theme.System {
|
||||
t.Errorf("theme %s is not flagged as built in", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedLeavesEditedBuiltInsAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an operator having edited the built-in theme.
|
||||
custom := "operator's midnight"
|
||||
if _, err := store.Update(ctx, "midnight", UpdateFields{Name: &custom}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
editedVersion, err := store.Get(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
|
||||
// Re-seeding must not revert the operator's work.
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("re-Seed: %v", err)
|
||||
}
|
||||
after, err := store.Get(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after reseed: %v", err)
|
||||
}
|
||||
if after.Name != custom {
|
||||
t.Errorf("name = %q, want the operator's %q", after.Name, custom)
|
||||
}
|
||||
if after.Version != editedVersion.Version {
|
||||
t.Errorf("version = %d, want %d; a reseed must not bump an edited theme", after.Version, editedVersion.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
entry := &domain.AuditEntry{
|
||||
Actor: "alice",
|
||||
ActorEmail: "alice@example.com",
|
||||
Action: domain.ActionThemePublish,
|
||||
EntityType: "theme",
|
||||
EntityID: "acme",
|
||||
Summary: "published acme",
|
||||
Detail: map[string]any{"version": 3},
|
||||
RemoteIP: "10.0.0.1",
|
||||
}
|
||||
if err := store.AppendAudit(ctx, entry); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
|
||||
entries, total, err := store.ListAudit(ctx, AuditFilter{EntityID: "acme"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAudit: %v", err)
|
||||
}
|
||||
if total != 1 || len(entries) != 1 {
|
||||
t.Fatalf("got %d entries (total %d), want 1", len(entries), total)
|
||||
}
|
||||
if entries[0].Detail["version"] != float64(3) {
|
||||
t.Errorf("detail = %v", entries[0].Detail)
|
||||
}
|
||||
|
||||
if _, _, err := store.ListAudit(ctx, AuditFilter{Actor: "bob"}); err != nil {
|
||||
t.Fatalf("ListAudit by actor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
old := store.Now().Add(-90 * 24 * time.Hour)
|
||||
if err := store.AppendAudit(ctx, &domain.AuditEntry{At: old, Action: domain.ActionAuthLogin, Actor: "old"}); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
if err := store.AppendAudit(ctx, &domain.AuditEntry{Action: domain.ActionAuthLogin, Actor: "new"}); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
|
||||
removed, err := store.PurgeAudit(ctx, store.Now().Add(-30*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeAudit: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("removed %d entries, want 1", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
value, err := store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
|
||||
if err != nil || value != "fallback" {
|
||||
t.Fatalf("Setting default = %q, %v; want the fallback", value, err)
|
||||
}
|
||||
if err := store.SetSetting(ctx, domain.SettingDefaultTheme, "midnight", "tester"); err != nil {
|
||||
t.Fatalf("SetSetting: %v", err)
|
||||
}
|
||||
value, err = store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
|
||||
if err != nil || value != "midnight" {
|
||||
t.Fatalf("Setting = %q, %v; want midnight", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUserOverwritesRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
identity := &domain.Identity{
|
||||
Subject: "sub-1", Email: "a@example.com", Name: "A",
|
||||
Roles: []domain.Role{domain.RoleAdmin}, Method: "oidc",
|
||||
}
|
||||
if err := store.UpsertUser(ctx, identity); err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
identity.Roles = []domain.Role{domain.RoleViewer}
|
||||
if err := store.UpsertUser(ctx, identity); err != nil {
|
||||
t.Fatalf("UpsertUser again: %v", err)
|
||||
}
|
||||
|
||||
users, err := store.ListUsers(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("got %d users, want 1 (upsert must not duplicate)", len(users))
|
||||
}
|
||||
if len(users[0].Roles) != 1 || users[0].Roles[0] != string(domain.RoleViewer) {
|
||||
t.Errorf("roles = %v, want the latest value", users[0].Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Themes != len(seed.Slugs()) {
|
||||
t.Errorf("themes = %d, want %d", stats.Themes, len(seed.Slugs()))
|
||||
}
|
||||
if stats.DatabaseSize <= 0 {
|
||||
t.Error("expected a non-zero database size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsAndWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("shared"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// WAL is supposed to let readers run while a writer holds the lock. This
|
||||
// test would deadlock or error without it.
|
||||
errs := make(chan error, 20)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
name := "name"
|
||||
if _, err := store.Update(ctx, "shared", UpdateFields{Name: &name}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := store.Get(ctx, "shared"); err != nil {
|
||||
errs <- err
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case err := <-errs:
|
||||
t.Fatalf("concurrent access failed: %v", err)
|
||||
}
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Errorf("concurrent access: %v", err)
|
||||
}
|
||||
}
|
||||
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) }
|
||||
152
internal/store/user_preferences.go
Normal file
152
internal/store/user_preferences.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Preferences returns what the subject has chosen for their own interface.
|
||||
//
|
||||
// A subject with no row, which is everybody until they save a preference once,
|
||||
// reads as empty rather than as an error: the zero value is the documented "no
|
||||
// choice made" state and every caller renders a default for it.
|
||||
//
|
||||
// A stored theme that has since been deleted or archived is reported as no
|
||||
// choice, for the same reason. Resolving the name against the themes table on
|
||||
// read keeps a stale preference from pinning the UI to something that no longer
|
||||
// exists, and it means an unpublish is reflected without a migration.
|
||||
func (s *Store) Preferences(ctx context.Context, subject string) (domain.UserPreferences, error) {
|
||||
if subject == "" {
|
||||
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
|
||||
var stored domain.UserPreferences
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT selected_theme, selected_appearance
|
||||
FROM user_preferences
|
||||
WHERE subject = ?`, subject).
|
||||
Scan(&stored.SelectedTheme, &stored.SelectedAppearance)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, translateError(err)
|
||||
}
|
||||
|
||||
if stored.SelectedTheme != "" {
|
||||
usable, err := s.themeUsable(ctx, stored.SelectedTheme)
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
if !usable {
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
}
|
||||
if stored.SelectedAppearance != "" && !stored.SelectedAppearance.Valid() {
|
||||
// A build that no longer knows an appearance must not hand it to a
|
||||
// client, which would set an attribute nothing styles.
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// SetPreferences records the subject's choice and returns what was stored.
|
||||
//
|
||||
// The theme must already exist and be published or archived. Drafts are rejected
|
||||
// because they are invisible to runtime consumers, so a preference pointing at one
|
||||
// would leave the UI rendering a theme it cannot load.
|
||||
func (s *Store) SetPreferences(ctx context.Context, subject string, prefs domain.UserPreferences) (domain.UserPreferences, error) {
|
||||
if subject == "" {
|
||||
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if err := prefs.Validate(); err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
|
||||
if prefs.SelectedTheme != "" {
|
||||
usable, err := s.themeUsable(ctx, prefs.SelectedTheme)
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
if !usable {
|
||||
return domain.UserPreferences{}, fmt.Errorf(
|
||||
"%w: selectedTheme %q is not a published theme", domain.ErrInvalid, prefs.SelectedTheme)
|
||||
}
|
||||
} else {
|
||||
// An appearance without a theme has nothing to select a scheme from, so it
|
||||
// is dropped rather than stored. Clearing a choice by sending an empty
|
||||
// theme therefore works even if the client leaves the appearance set.
|
||||
prefs.SelectedAppearance = ""
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO user_preferences (subject, selected_theme, selected_appearance, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(subject) DO UPDATE SET
|
||||
selected_theme = excluded.selected_theme,
|
||||
selected_appearance = excluded.selected_appearance,
|
||||
updated_at = excluded.updated_at`,
|
||||
subject, prefs.SelectedTheme, string(prefs.SelectedAppearance), s.timestamp())
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, translateError(err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// ClearPreferences forgets the subject's choice, which is how the UI returns to
|
||||
// its own default. It reports whether a row was removed.
|
||||
func (s *Store) ClearPreferences(ctx context.Context, subject string) (bool, error) {
|
||||
if subject == "" {
|
||||
return false, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM user_preferences WHERE subject = ?`, subject)
|
||||
if err != nil {
|
||||
return false, translateError(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
// Drivers that cannot report this are not a reason to fail the request:
|
||||
// the row is gone either way, which is all the caller asked for.
|
||||
return true, nil //nolint:nilerr // see comment above
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// ThemePreferenceCount reports how many subjects have chosen a theme, which the
|
||||
// admin UI shows so an editor can see who a change would affect before making it.
|
||||
func (s *Store) ThemePreferenceCount(ctx context.Context, slug string) (int, error) {
|
||||
if err := domain.ValidateSlug(slug); err != nil {
|
||||
return 0, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM user_preferences WHERE selected_theme = ?`, slug).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, translateError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// themeUsable reports whether a slug names a theme a runtime consumer may load.
|
||||
// Archived themes qualify because the last published revision of an archived theme
|
||||
// stays resolvable for consumers pinned to it.
|
||||
func (s *Store) themeUsable(ctx context.Context, slug string) (bool, error) {
|
||||
var status string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT status FROM themes WHERE slug = ?`, slug).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, translateError(err)
|
||||
}
|
||||
switch domain.ThemeStatus(status) {
|
||||
case domain.StatusPublished, domain.StatusArchived:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
262
internal/store/user_preferences_test.go
Normal file
262
internal/store/user_preferences_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
func TestPreferencesDefaultToNoChoice(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A subject nobody has saved anything for reads as empty rather than failing,
|
||||
// because empty is the documented "no choice" state.
|
||||
prefs, err := s.Preferences(ctx, "nobody")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if prefs.SelectedTheme != "" || prefs.SelectedAppearance != "" {
|
||||
t.Fatalf("preferences = %+v, want the zero value", prefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
published := publishTheme(t, s, "midnight")
|
||||
|
||||
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: published.Slug,
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
if stored.SelectedTheme != "midnight" || stored.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read != stored {
|
||||
t.Fatalf("read = %+v, want %+v", read, stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesCanBeClearedAndOverwritten(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
|
||||
// Writing again replaces rather than accumulating.
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("overwrite preferences: %v", err)
|
||||
}
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("appearance = %q, want dark", read.SelectedAppearance)
|
||||
}
|
||||
|
||||
// An empty theme is the absence of a choice and is storable.
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{}); err != nil {
|
||||
t.Fatalf("clear by writing empty: %v", err)
|
||||
}
|
||||
read, err = s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "" {
|
||||
t.Fatalf("theme = %q, want empty", read.SelectedTheme)
|
||||
}
|
||||
|
||||
removed, err := s.ClearPreferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("clear preferences: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("clear reported no row removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesRejectUnknownAndDraftThemes covers the two ways a name can fail:
|
||||
// it does not exist, or it exists but runtime consumers cannot load it.
|
||||
func TestPreferencesRejectUnknownAndDraftThemes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
|
||||
t.Fatalf("create theme: %v", err)
|
||||
}
|
||||
|
||||
for _, slug := range []string{"no-such-theme", "draft-one"} {
|
||||
_, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: slug,
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("SetPreferences(%q) = %v, want ErrInvalid", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesFallBackWhenTheThemeDisappears is the reason Preferences resolves
|
||||
// the name on read: a theme that is later deleted must not leave somebody pinned to
|
||||
// a selection the UI cannot load.
|
||||
func TestPreferencesFallBackWhenTheThemeDisappears(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
theme := publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: theme.Slug,
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, theme.Slug); err != nil {
|
||||
t.Fatalf("delete theme: %v", err)
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "" || read.SelectedAppearance != "" {
|
||||
t.Fatalf("preferences = %+v, want the zero value once the theme is gone", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesRejectMalformedInput(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
rejected := map[string]domain.UserPreferences{
|
||||
"uppercase slug": {SelectedTheme: "Midnight"},
|
||||
"slug with a space": {SelectedTheme: "mid night"},
|
||||
"leading dash": {SelectedTheme: "-midnight"},
|
||||
"unknown appearance": {
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: "sepia",
|
||||
},
|
||||
}
|
||||
for name, prefs := range rejected {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := s.SetPreferences(ctx, "user-1", prefs); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("SetPreferences(%+v) = %v, want ErrInvalid", prefs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clearing with an empty theme is valid, and an appearance sent alongside it
|
||||
// is dropped rather than stored as a scheme with nothing to apply it to.
|
||||
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clearing with a stray appearance: %v", err)
|
||||
}
|
||||
if stored.SelectedAppearance != "" {
|
||||
t.Fatalf("stored = %+v, want the appearance dropped", stored)
|
||||
}
|
||||
|
||||
if _, err := s.Preferences(ctx, ""); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("Preferences(\"\") = %v, want ErrInvalid", err)
|
||||
}
|
||||
if _, err := s.SetPreferences(ctx, "", domain.UserPreferences{}); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("SetPreferences with empty subject = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesSurviveLogin matters because UpsertUser rewrites the users row on
|
||||
// every login, which is exactly why preferences are not columns on it.
|
||||
func TestPreferencesSurviveLogin(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := s.UpsertUser(ctx, &domain.Identity{
|
||||
Subject: "user-1", Email: "u@example.test", Roles: []domain.Role{domain.RoleViewer},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "midnight" || read.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("preferences = %+v, want them preserved across logins", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemePreferenceCount(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
for _, subject := range []string{"a", "b"} {
|
||||
if _, err := s.SetPreferences(ctx, subject, domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences for %s: %v", subject, err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := s.ThemePreferenceCount(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("count = %d, want 2", count)
|
||||
}
|
||||
if _, err := s.ThemePreferenceCount(ctx, "Not A Slug"); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("ThemePreferenceCount with a bad slug = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTheme creates a theme and publishes it, returning the stored value.
|
||||
//
|
||||
// StatusPublished is a constant rather than a variable, so the pointer
|
||||
// UpdateFields wants has to be taken from a local.
|
||||
func publishTheme(t *testing.T, s *Store, slug string) *domain.Theme {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := s.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("create theme %s: %v", slug, err)
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
theme, err := s.Update(ctx, slug, UpdateFields{Status: &published})
|
||||
if err != nil {
|
||||
t.Fatalf("publish theme %s: %v", slug, err)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue