initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

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

62
internal/store/backup.go Normal file
View 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
}

View 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)
}
}

View 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
}

View 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 ''
);

View 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;

View 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);

View 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
View 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
View 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)
}

View 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
View file

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

View 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
}
}

View 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
}