Files
silo-server/internal/settingscontract/load.go
T
QuickandClaude Opus 5 7a29fcd0e6 feat(settings): add the one-time migration planner
The conversion rules from legacy settings storage to canonical values, as
ordinary Go rather than twice in two SQL dialects. Both backends read their
own rows, hand them to Plan, and write what comes back — so the decisions
are testable without a database and SQLite and Postgres cannot drift apart
in what they decide.

The rules that needed care, each pinned by a test:

Column defaults are not choices. quality_preference is NOT NULL DEFAULT
'1080p' while the contract defaults to auto, so migrating the column
unconditionally would pin every profile in the install to 1080p having
never chosen it — and that stored value would then outrank the contract
default forever. Same for language 'en', subtitle_mode 'auto', and
show_forced_subtitles true.

The empty string is unset, not a value. The legacy string API had no way to
send null, so both Android and web spell "clear my choice" as "". Storing
that would make a cleared setting outrank the default.

Legacy quality decomposes rather than rejects. Every compound value maps to
a resolution and a bitrate from the ladder in useTranscodeQuality.ts, so
nothing lands in the rejects table.

Account rows fan out to every profile, which is the account-to-profile move
the contract makes for appearance and search scope: a household that shared
one theme each end up owning theirs.

Legacy strings become typed JSON — "true" to true, "30" to 30 — or every
generated binding would fail to decode what the migration wrote.

Nullability differs per backend, so profile columns arrive as pointers and
the caller resolves "chose the default" versus "never written" when it
reads. jellycompat's DisplayPreferences blobs ride the same table under
synthetic keys and are left alone; they are that subsystem's storage.

Everything that cannot convert is recorded with a reason rather than
dropped, and a final test asserts every planned row would be accepted by
the mutation endpoint's own validation.

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

237 lines
7.0 KiB
Go

package settingscontract
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"path"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v6"
settingsv1 "github.com/Silo-Server/silo-server/contracts/settings/v1"
)
// contractFS is the embedded canonical contract. Clients vendor a pinned copy
// of the same files.
var contractFS = settingsv1.FS
const (
manifestPath = "manifest.json"
manifestSchemaPath = "manifest.schema.json"
schemasDir = "schemas"
)
var (
loadOnce sync.Once
loaded *Manifest
loadedErr error
loadedRaw []byte
loadedSchemas map[string][]byte
objSchemas map[string]*jsonschema.Schema
)
// loaded contract, as returned by load(). Keeping this a value rather than
// having load() assign the package globals means load() stays pure and can be
// called with a test filesystem without clobbering the process-wide contract.
type contract struct {
manifest *Manifest
raw []byte
schemaRaw map[string][]byte
compiled map[string]*jsonschema.Schema
}
// Load returns the embedded canonical manifest, parsed and fully validated.
//
// It is loaded once per process. A malformed or self-inconsistent manifest is a
// build-time defect, not a runtime condition: the contract tests fail on it, and
// callers that reach this at runtime should treat the error as fatal at startup
// rather than degrading.
func Load() (*Manifest, error) {
loadOnce.Do(func() {
result, err := load(contractFS)
if err != nil {
loadedErr = err
return
}
loaded = result.manifest
loadedRaw = result.raw
loadedSchemas = result.schemaRaw
objSchemas = result.compiled
})
return loaded, loadedErr
}
// MustLoad returns the embedded manifest or panics. For use in server startup
// where a broken embedded contract cannot be recovered from.
func MustLoad() *Manifest {
m, err := Load()
if err != nil {
panic(fmt.Sprintf("settingscontract: embedded manifest is invalid: %v", err))
}
return m
}
// RawBytes returns the embedded manifest file exactly as checked in.
func RawBytes() ([]byte, error) {
if _, err := Load(); err != nil {
return nil, err
}
return append([]byte(nil), loadedRaw...), nil
}
// SchemaBytes returns every value schema file exactly as checked in, keyed by
// file name. These decide which object-typed values the contract accepts, so
// they are part of its identity — see ETag.
func SchemaBytes() (map[string][]byte, error) {
if _, err := Load(); err != nil {
return nil, err
}
out := make(map[string][]byte, len(loadedSchemas))
for name, body := range loadedSchemas {
out[name] = append([]byte(nil), body...)
}
return out, nil
}
// ObjectSchema returns the compiled JSON Schema for an object-typed value.
func ObjectSchema(ref string) (*jsonschema.Schema, bool) {
if _, err := Load(); err != nil {
return nil, false
}
schema, ok := objSchemas[ref]
return schema, ok
}
// ObjectSchemas returns every compiled object schema, keyed by schema_ref.
//
// ValidateValue needs the whole set rather than one entry, and it is called
// from outside this package now — the migration planner and the mutation
// endpoint both validate values they did not author. Returning the live map
// rather than a copy matches ValidateValue's own signature; callers treat it as
// read-only, and it is built once at load.
func ObjectSchemas() map[string]*jsonschema.Schema {
if _, err := Load(); err != nil {
return nil
}
return objSchemas
}
func load(fsys fs.FS) (contract, error) {
raw, err := fs.ReadFile(fsys, manifestPath)
if err != nil {
return contract{}, fmt.Errorf("reading manifest: %w", err)
}
if err := validateAgainstManifestSchema(fsys, raw); err != nil {
return contract{}, err
}
var manifest Manifest
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return contract{}, fmt.Errorf("parsing manifest: %w", err)
}
if err := manifest.index(); err != nil {
return contract{}, err
}
schemaRaw, compiled, err := compileObjectSchemas(fsys)
if err != nil {
return contract{}, err
}
if err := manifest.Validate(compiled); err != nil {
return contract{}, err
}
return contract{manifest: &manifest, raw: raw, schemaRaw: schemaRaw, compiled: compiled}, nil
}
// validateAgainstManifestSchema checks the manifest file against its own JSON
// Schema before Go decoding, so shape errors report as schema violations with a
// location rather than as opaque unmarshal failures.
func validateAgainstManifestSchema(fsys fs.FS, raw []byte) error {
schemaBytes, err := fs.ReadFile(fsys, manifestSchemaPath)
if err != nil {
return fmt.Errorf("reading manifest schema: %w", err)
}
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaBytes))
if err != nil {
return fmt.Errorf("parsing manifest schema: %w", err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource(manifestSchemaPath, schemaDoc); err != nil {
return fmt.Errorf("registering manifest schema: %w", err)
}
schema, err := compiler.Compile(manifestSchemaPath)
if err != nil {
return fmt.Errorf("compiling manifest schema: %w", err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("parsing manifest: %w", err)
}
if err := schema.Validate(doc); err != nil {
return fmt.Errorf("manifest does not satisfy manifest.schema.json: %w", err)
}
return nil
}
// compileObjectSchemas compiles every schema under schemas/ so object-typed
// values and their defaults can be validated. Compiling all of them up front
// also catches a malformed schema file that no definition happens to reference
// yet.
func compileObjectSchemas(fsys fs.FS) (map[string][]byte, map[string]*jsonschema.Schema, error) {
entries, err := fs.ReadDir(fsys, schemasDir)
if err != nil {
return nil, nil, fmt.Errorf("reading value schema directory: %w", err)
}
compiler := jsonschema.NewCompiler()
raw := make(map[string][]byte, len(entries))
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// schema_ref can only name a .json file, so anything else here is not a
// value schema. Parsing it anyway would turn a stray file — an editor
// backup, a .DS_Store — into a panic at startup via MustLoad.
if !strings.HasSuffix(name, ".json") {
continue
}
body, err := fs.ReadFile(fsys, path.Join(schemasDir, name))
if err != nil {
return nil, nil, fmt.Errorf("reading value schema %s: %w", name, err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(body))
if err != nil {
return nil, nil, fmt.Errorf("parsing value schema %s: %w", name, err)
}
if err := compiler.AddResource(name, doc); err != nil {
return nil, nil, fmt.Errorf("registering value schema %s: %w", name, err)
}
raw[name] = body
names = append(names, name)
}
compiled := make(map[string]*jsonschema.Schema, len(names))
for _, name := range names {
schema, err := compiler.Compile(name)
if err != nil {
return nil, nil, fmt.Errorf("compiling value schema %s: %w", name, err)
}
compiled[name] = schema
}
return raw, compiled, nil
}