Files
silo-server/internal/userstore/settingvalues.go
T
QuickandClaude Opus 5 05af63ea2b feat(settings): add canonical typed storage for the settings contract
The cross-platform settings contract needs one typed store behind it before a
resolver, routes or a migration can exist. This adds that storage to both
user-store backends and holds them to identical behavior.

PostgreSQL gets user_setting_values with the scope CHECK constraints, the five
partial unique indexes that enforce one explicit value per identity, and the
covering indexes the one-query read path needs, plus user_setting_mutations for
mutation_id idempotency and the inert user_setting_migration_rejects audit
table. The per-user SQLite store gets the same shape minus user_id, since that
database is already user-scoped.

The UserStore interface grows the typed operations: read one explicit value at
one scope, collect every candidate row for a resolution request in a single
query, upsert with a revision increment, unset, and the idempotency receipt
operations. The resolution read deliberately returns unranked candidates so the
resolver can rank in Go — one query per request, never one per scope, which the
pgx query-count test pins.

Delete behavior is application-enforced. Neither backend can inherit it from
constraints: the SQLite store declares no foreign keys, and library, series and
device columns are not FK targets in Postgres either. Profile deletion cascades
to profile-anchored values while account scope survives, forgetting a device
clears its profile_device values alongside the legacy overrides, and the
library/series purges remove only what is scoped to that entity.

The shared conformance suite covers all of it, including the set-versus-unset
distinction for false, 0, "" and null, so a divergence between the two backends
fails a test rather than reaching a client.

Part of #376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 01:22:23 +00:00

233 lines
7.9 KiB
Go

package userstore
import (
"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")
// 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
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.
func (id SettingIdentity) Validate() error {
if strings.TrimSpace(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 && strings.TrimSpace(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)
}
wantDevice := id.Scope == settingscontract.ScopeProfileDevice
if wantDevice && strings.TrimSpace(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 && strings.TrimSpace(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
}
// 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
// ProfileID drops every profile-anchored scope when empty, leaving only
// account-scope candidates.
ProfileID string
// DeviceID drops profile_device candidates when empty, which is what an
// unidentified client (jellycompat's DisplayPreferences seed) needs.
DeviceID string
// 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),
ProfileID: strings.TrimSpace(q.ProfileID),
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
}