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>
This commit is contained in:
Quick
2026-07-27 01:22:23 +00:00
co-authored by Claude Opus 5
parent 90224c583f
commit 05af63ea2b
20 changed files with 2534 additions and 8 deletions
+34
View File
@@ -2,6 +2,7 @@ package access
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
@@ -271,6 +272,39 @@ func (s stubStore) UpsertLibraryPlaybackPreference(context.Context, userstore.Li
func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int) error {
panic("unused")
}
func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValue(context.Context, userstore.SettingIdentity) (bool, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForProfile(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForDevice(context.Context, string, string) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForLibrary(context.Context, int) (int64, error) {
panic("unused")
}
func (s stubStore) DeleteSettingValuesForSeries(context.Context, string) (int64, error) {
panic("unused")
}
func (s stubStore) GetSettingMutation(context.Context, string) (*userstore.SettingMutationRecord, error) {
panic("unused")
}
func (s stubStore) PutSettingMutation(context.Context, userstore.SettingMutationRecord) (userstore.SettingMutationRecord, bool, error) {
panic("unused")
}
func (s stubStore) DeleteExpiredSettingMutations(context.Context, time.Time) (int64, error) {
panic("unused")
}
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(