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>
233 lines
7.9 KiB
Go
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
|
|
}
|