Files
silo-server/internal/userstore/settingvalues.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

328 lines
12 KiB
Go

package userstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/settingscontract"
)
// ErrInvalidSettingIdentity is returned when a setting identity does not match
// the columns its scope requires. Both backends validate through
// SettingIdentity.Validate, so a request rejected by one is rejected by the
// other with the same reason.
var ErrInvalidSettingIdentity = errors.New("invalid setting identity")
// ErrInvalidSettingValue is returned when a stored value is not well-formed
// JSON. The store checks only structural validity: whether a value satisfies its
// definition is settingscontract.ValidateValue's job, and that is the single
// validation path.
var ErrInvalidSettingValue = errors.New("invalid setting value")
// ErrSettingValueRevisionConflict reports that a compare-and-set write did
// not observe the revision it expected. It is deliberately a storage-level
// primitive rather than a public settings precondition: semantic mutation
// endpoints use it to merge concurrent edits without making every client
// implement a read/rebase/retry loop.
var ErrSettingValueRevisionConflict = errors.New("setting value revision conflict")
// SettingIdentity addresses exactly one canonical setting row: the key plus the
// context columns its scope requires.
//
// Only the fields belonging to Scope are meaningful; Validate enforces that and
// rejects anything else, so an identity that reaches SQL always matches the
// table's CHECK constraints.
type SettingIdentity struct {
Key string
Scope settingscontract.Scope
ProfileID string
ClientFamily settingscontract.ClientFamily
DeviceID string
LibraryID int
SeriesID string
}
// Validate reports whether the identity is addressable. It mirrors the scope
// CHECK constraint on user_setting_values so an invalid identity is rejected
// before it reaches either backend rather than surfacing as a driver error whose
// text differs between them.
//
// It also rejects ids that are not in canonical (trimmed) form rather than
// merely non-empty after trimming: the write path binds these fields verbatim,
// while resolution queries bind their trimmed forms, so a value written under
// " p1 " would validate, persist, and then never be found by a resolution for
// p1 — a silently orphaned row.
func (id SettingIdentity) Validate() error {
for field, value := range map[string]string{
"key": id.Key,
"profile id": id.ProfileID,
"client family": string(id.ClientFamily),
"device id": id.DeviceID,
"series id": id.SeriesID,
} {
if value != strings.TrimSpace(value) {
return fmt.Errorf("%w: %s %q has surrounding whitespace",
ErrInvalidSettingIdentity, field, value)
}
}
if id.Key == "" {
return fmt.Errorf("%w: key is required", ErrInvalidSettingIdentity)
}
if !id.Scope.IsRemote() {
return fmt.Errorf("%w: %q is not a remote scope", ErrInvalidSettingIdentity, id.Scope)
}
needProfile := id.Scope != settingscontract.ScopeAccount
if needProfile && id.ProfileID == "" {
return fmt.Errorf("%w: scope %q requires a profile id", ErrInvalidSettingIdentity, id.Scope)
}
if !needProfile && id.ProfileID != "" {
return fmt.Errorf("%w: scope %q must not carry a profile id", ErrInvalidSettingIdentity, id.Scope)
}
wantClientFamily := id.Scope == settingscontract.ScopeProfileClient
if wantClientFamily && !id.ClientFamily.Valid() {
return fmt.Errorf("%w: scope %q requires client family tv, mobile, tablet, desktop or web",
ErrInvalidSettingIdentity, id.Scope)
}
if !wantClientFamily && id.ClientFamily != "" {
return fmt.Errorf("%w: scope %q must not carry a client family", ErrInvalidSettingIdentity, id.Scope)
}
wantDevice := id.Scope == settingscontract.ScopeProfileDevice
if wantDevice && id.DeviceID == "" {
return fmt.Errorf("%w: scope %q requires a device id", ErrInvalidSettingIdentity, id.Scope)
}
if !wantDevice && id.DeviceID != "" {
return fmt.Errorf("%w: scope %q must not carry a device id", ErrInvalidSettingIdentity, id.Scope)
}
wantLibrary := id.Scope == settingscontract.ScopeProfileLibrary
if wantLibrary && id.LibraryID <= 0 {
return fmt.Errorf("%w: scope %q requires a library id", ErrInvalidSettingIdentity, id.Scope)
}
if !wantLibrary && id.LibraryID != 0 {
return fmt.Errorf("%w: scope %q must not carry a library id", ErrInvalidSettingIdentity, id.Scope)
}
wantSeries := id.Scope == settingscontract.ScopeProfileSeries
if wantSeries && id.SeriesID == "" {
return fmt.Errorf("%w: scope %q requires a series id", ErrInvalidSettingIdentity, id.Scope)
}
if !wantSeries && id.SeriesID != "" {
return fmt.Errorf("%w: scope %q must not carry a series id", ErrInvalidSettingIdentity, id.Scope)
}
return nil
}
// SettingValue is one explicit value stored at one scope. Unset is the absence
// of a row, which is distinct from false, 0, "" and JSON null.
type SettingValue struct {
SettingIdentity
// Value is the stored JSON. It is whatever settingscontract.NormalizeValue
// produced; the store neither interprets nor re-normalizes it.
Value json.RawMessage
// Revision increments on every write to this row.
Revision int64
// CreatedAt and UpdatedAt are RFC3339 UTC timestamps.
CreatedAt string
UpdatedAt string
}
// SettingValueCompareAndSetter is the optional atomic-write capability used
// by settings whose public API mutates one member of a shared document. An
// expected revision of zero means the row must not exist; a positive revision
// means that exact row revision must still be current.
//
// UserStore intentionally does not embed this interface. Transaction-scoped
// and test-only stores that never serve semantic mutation endpoints need not
// expose an operation they cannot perform, while both production backends do.
type SettingValueCompareAndSetter interface {
CompareAndSetSettingValue(
ctx context.Context,
id SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*SettingValue, error)
}
// SettingMutationWriter is the transaction-scoped settings surface used by an
// idempotent mutation. Implementations must keep every call made by one
// WithSettingMutationTransaction callback on the same database transaction.
// That makes the setting write and its replay receipt one durable unit.
type SettingMutationWriter interface {
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
CompareAndSetSettingValue(
ctx context.Context,
id SettingIdentity,
value json.RawMessage,
expectedRevision int64,
) (*SettingValue, error)
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
}
// SettingMutationTransactioner atomically serializes one mutation id, applies
// its setting write, and records its receipt. The callback commits only when it
// returns nil. A crash or callback error therefore leaves neither half applied.
// Concurrent callbacks for the same mutationID must execute one at a time.
//
// This is optional rather than embedded in UserStore because only production
// stores serving idempotent mutation routes need to expose transaction state.
type SettingMutationTransactioner interface {
WithSettingMutationTransaction(
ctx context.Context,
mutationID string,
fn func(SettingMutationWriter) error,
) error
}
// SettingResolutionQuery describes one resolution request: the keys to resolve
// and every identity they may resolve against.
//
// It is deliberately shaped for the batched read. A season view resolving n
// items passes every library and series id in one query and the resolver ranks
// the returned candidate rows by each definition's resolution order in Go. Five
// sequential index lookups per key per item is a rejected implementation.
type SettingResolutionQuery struct {
Keys []string
// ProfileIDs are the profiles in play. Empty drops every profile-anchored
// scope, leaving only account-scope candidates.
//
// Several ids is the household shape: GET /profiles serves a preference
// block per profile, so it passes every profile once and the resolver ranks
// each profile's candidates in Go. One read per profile is a rejected
// implementation for the same reason one read per item is.
ProfileIDs []string
// DeviceID drops profile_device candidates when empty, which is what an
// unidentified client (jellycompat's DisplayPreferences seed) needs.
DeviceID string
// ClientFamily drops profile_client candidates when empty. Unlike the
// device registry's platform metadata, this is a closed canonical enum and
// is supplied explicitly by first-party clients.
ClientFamily settingscontract.ClientFamily
// LibraryIDs and SeriesIDs carry the content contexts of a batch. Empty
// slices drop their scope from the candidate set.
LibraryIDs []int
SeriesIDs []string
}
// Normalized returns the query with blanks removed and duplicates collapsed, in
// a stable order. Both backends bind the normalized form, so an empty or
// whitespace-only id never reaches SQL as a literal and the two backends issue
// the same predicate for the same request.
func (q SettingResolutionQuery) Normalized() SettingResolutionQuery {
return SettingResolutionQuery{
Keys: compactStrings(q.Keys),
ProfileIDs: compactStrings(q.ProfileIDs),
ClientFamily: settingscontract.ClientFamily(strings.TrimSpace(string(q.ClientFamily))),
DeviceID: strings.TrimSpace(q.DeviceID),
LibraryIDs: compactPositiveInts(q.LibraryIDs),
SeriesIDs: compactStrings(q.SeriesIDs),
}
}
// SettingMutationRecord is the idempotency receipt for one mutation.
//
// The mutation endpoint treats a mutation_id as idempotent for at least 30 days:
// repeating the same id and body returns the prior Result, and reusing an id
// with different content is a mutation_id_conflict, which is what RequestHash
// distinguishes.
type SettingMutationRecord struct {
MutationID string
RequestHash string
// Result is the serialized per-mutation result returned to a repeat of the
// same request.
Result json.RawMessage
// CreatedAt is set by the store when the record is inserted.
CreatedAt time.Time
// ExpiresAt bounds retention. It is not self-enforcing: a sweeper deletes
// expired rows through DeleteExpiredSettingMutations.
ExpiresAt time.Time
}
// Validate reports whether the record is storable.
func (r SettingMutationRecord) Validate() error {
if strings.TrimSpace(r.MutationID) == "" {
return fmt.Errorf("%w: mutation id is required", ErrInvalidSettingIdentity)
}
if strings.TrimSpace(r.RequestHash) == "" {
return fmt.Errorf("%w: request hash is required", ErrInvalidSettingIdentity)
}
if r.ExpiresAt.IsZero() {
return fmt.Errorf("%w: expires_at is required", ErrInvalidSettingIdentity)
}
return ValidateSettingValueJSON(r.Result)
}
// ValidateSettingValueJSON checks that raw is a non-empty, well-formed JSON
// document. It is the only value check the store makes: the contract layer has
// already validated the value against its definition through
// settingscontract.NormalizeValue, and duplicating that here would be the second
// validator this contract exists to remove.
func ValidateSettingValueJSON(raw json.RawMessage) error {
if len(raw) == 0 {
return fmt.Errorf("%w: value is required", ErrInvalidSettingValue)
}
if !json.Valid(raw) {
return fmt.Errorf("%w: value is not well-formed JSON", ErrInvalidSettingValue)
}
return nil
}
func compactStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, dup := seen[trimmed]; dup {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
sort.Strings(out)
return out
}
func compactPositiveInts(values []int) []int {
if len(values) == 0 {
return nil
}
seen := make(map[int]struct{}, len(values))
out := make([]int, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
sort.Ints(out)
return out
}