feat(settings): run the one-time migration on the SQLite backend
Wires the planner to real storage as userdb migration V15. V14 created the tables; this fills them. It runs inside runMigrations' existing transaction, so a database either comes out fully migrated or untouched — a partial migration is the one state neither the operator's backup nor a rollback covers. Pinned by a test that rolls back and asserts nothing was left behind. Two things the wiring had to get right that the planner could not see: Reject identities are JSON. Postgres declares that column jsonb NOT NULL and SQLite guards it with a json_valid CHECK, so the free-form "profile=p1 device=d1" the planner emitted would have failed to insert — on exactly the rows the table exists to record. They are structured documents now, which is also queryable. Subtitle and audio preferences are two tables keyed the same way, so they merge into one per-series record before planning. Converting them independently would have produced two rows racing for the same identity. Every legacy read tolerates a missing table, since this runs against databases created at any schema version, and preferred_metadata_language is deliberately absent: that column exists only in the Postgres schema. Tested end to end against a real database rather than only through the planner — the rows land, satisfy the scope CHECK and the partial unique indexes, and hold valid JSON. Also covers the empty-install case and asserts a second run fails rather than silently doubling every value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,14 +103,33 @@ type Row struct {
|
||||
// Reject records a legacy value that could not be converted. It is written to
|
||||
// user_setting_migration_rejects so an operator can see exactly what did not
|
||||
// survive, rather than discovering it from a support ticket.
|
||||
//
|
||||
// Identity is JSON because both backends store it as one: Postgres declares the
|
||||
// column jsonb NOT NULL and SQLite guards it with a json_valid CHECK. A
|
||||
// free-form description would violate the schema on write, and a structured one
|
||||
// is queryable — "every reject for profile p1" is a jsonb predicate rather than
|
||||
// a LIKE over prose.
|
||||
type Reject struct {
|
||||
SourceTable string
|
||||
SourceKey string
|
||||
Identity string
|
||||
Identity json.RawMessage
|
||||
Value string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// identityJSON builds the reject identity document. Only the fields that locate
|
||||
// the row are emitted, so a profile-column reject does not carry an empty
|
||||
// device id.
|
||||
func identityJSON(fields map[string]any) json.RawMessage {
|
||||
encoded, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
// Only strings and ints reach here, so this cannot fire; an empty
|
||||
// object still satisfies both backends' JSON constraint.
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
// Result is what one user's migration produced.
|
||||
type Result struct {
|
||||
Rows []Row
|
||||
@@ -145,6 +164,15 @@ const (
|
||||
// two canonical keys rather than converting to one.
|
||||
const keyPreferredQuality = "playback.preferred_quality"
|
||||
|
||||
// fieldProfileID is the identity field every non-account reject carries.
|
||||
const fieldProfileID = "profile_id"
|
||||
|
||||
// accountIdentity locates a reject from the account-wide key/value table, which
|
||||
// has no profile, device or content to name.
|
||||
func accountIdentity() json.RawMessage {
|
||||
return identityJSON(map[string]any{"scope": "account"})
|
||||
}
|
||||
|
||||
// legacyQualityDecomposition maps each legacy compound quality value to the two
|
||||
// axes that replaced it.
|
||||
//
|
||||
@@ -231,8 +259,8 @@ func (p *Planner) Plan(in Input) Result {
|
||||
// planProfiles converts the six preference columns on user_profiles.
|
||||
func (p *Planner) planProfiles(profiles []LegacyProfile, res *Result) {
|
||||
for _, profile := range profiles {
|
||||
id := func(field string) string {
|
||||
return fmt.Sprintf("profile=%s column=%s", profile.ID, field)
|
||||
id := func(field string) json.RawMessage {
|
||||
return identityJSON(map[string]any{fieldProfileID: profile.ID, "column": field})
|
||||
}
|
||||
|
||||
// Language columns: the empty string is the legacy spelling of "no
|
||||
@@ -290,8 +318,9 @@ func (p *Planner) planAccountSettings(
|
||||
if !ok {
|
||||
res.Rejects = append(res.Rejects, Reject{
|
||||
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
||||
Value: setting.Value,
|
||||
Reason: "no contract definition; the key was only ever accepted by the unknown-key extension bag",
|
||||
Identity: accountIdentity(),
|
||||
Value: setting.Value,
|
||||
Reason: "no contract definition; the key was only ever accepted by the unknown-key extension bag",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -300,7 +329,8 @@ func (p *Planner) planAccountSettings(
|
||||
if err != nil {
|
||||
res.Rejects = append(res.Rejects, Reject{
|
||||
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
||||
Value: setting.Value, Reason: err.Error(),
|
||||
Identity: accountIdentity(),
|
||||
Value: setting.Value, Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -312,8 +342,9 @@ func (p *Planner) planAccountSettings(
|
||||
if !def.AllowsScope(scope) {
|
||||
res.Rejects = append(res.Rejects, Reject{
|
||||
SourceTable: sourceUserSettings, SourceKey: setting.Key,
|
||||
Value: setting.Value,
|
||||
Reason: fmt.Sprintf("%s does not allow profile scope", key),
|
||||
Identity: accountIdentity(),
|
||||
Value: setting.Value,
|
||||
Reason: fmt.Sprintf("%s does not allow profile scope", key),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -328,7 +359,9 @@ func (p *Planner) planAccountSettings(
|
||||
func (p *Planner) planDeviceSettings(devices []LegacyDeviceSetting, res *Result) {
|
||||
for _, row := range devices {
|
||||
key := canonicalKey(row.Key)
|
||||
identity := fmt.Sprintf("profile=%s device=%s", row.ProfileID, row.DeviceID)
|
||||
identity := identityJSON(map[string]any{
|
||||
fieldProfileID: row.ProfileID, "device_id": row.DeviceID,
|
||||
})
|
||||
|
||||
if key == keyPreferredQuality {
|
||||
p.addQuality(res, sourceUserDeviceSettings, identity,
|
||||
@@ -375,7 +408,9 @@ func (p *Planner) planDeviceSettings(devices []LegacyDeviceSetting, res *Result)
|
||||
func (p *Planner) planSeriesPrefs(prefs []LegacySeriesPreference, res *Result) {
|
||||
for _, pref := range prefs {
|
||||
base := Row{ProfileID: pref.ProfileID, SeriesID: pref.SeriesID}
|
||||
identity := fmt.Sprintf("profile=%s series=%s", pref.ProfileID, pref.SeriesID)
|
||||
identity := identityJSON(map[string]any{
|
||||
fieldProfileID: pref.ProfileID, "series_id": pref.SeriesID,
|
||||
})
|
||||
p.addPlaybackTriple(res, sourceSeriesPrefs, identity,
|
||||
settingscontract.ScopeProfileSeries, base,
|
||||
pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles)
|
||||
@@ -385,7 +420,9 @@ func (p *Planner) planSeriesPrefs(prefs []LegacySeriesPreference, res *Result) {
|
||||
func (p *Planner) planLibraryPrefs(prefs []LegacyLibraryPreference, res *Result) {
|
||||
for _, pref := range prefs {
|
||||
base := Row{ProfileID: pref.ProfileID, LibraryID: pref.LibraryID}
|
||||
identity := fmt.Sprintf("profile=%s library=%d", pref.ProfileID, pref.LibraryID)
|
||||
identity := identityJSON(map[string]any{
|
||||
fieldProfileID: pref.ProfileID, "library_id": pref.LibraryID,
|
||||
})
|
||||
p.addPlaybackTriple(res, sourceLibraryPrefs, identity,
|
||||
settingscontract.ScopeProfileLibrary, base,
|
||||
pref.AudioLanguage, pref.SubtitleLanguage, pref.SubtitleMode, pref.ShowForcedSubtitles)
|
||||
@@ -396,7 +433,7 @@ func (p *Planner) planLibraryPrefs(prefs []LegacyLibraryPreference, res *Result)
|
||||
// library rows share. Both tables carry the same columns with the same
|
||||
// semantics, so they convert identically at different scopes.
|
||||
func (p *Planner) addPlaybackTriple(
|
||||
res *Result, sourceTable, identity string,
|
||||
res *Result, sourceTable string, identity json.RawMessage,
|
||||
scope settingscontract.Scope, base Row,
|
||||
audio, subtitle, mode *string, forced *bool,
|
||||
) {
|
||||
@@ -422,7 +459,7 @@ func (p *Planner) addPlaybackTriple(
|
||||
// spelling of "no preference" and produces no row; so does a value still equal
|
||||
// to the column default.
|
||||
func (p *Planner) addLanguage(
|
||||
res *Result, sourceTable, identity, key string,
|
||||
res *Result, sourceTable string, identity json.RawMessage, key string,
|
||||
scope settingscontract.Scope, base Row, raw *string, columnDefault string,
|
||||
) {
|
||||
value := strings.TrimSpace(deref(raw))
|
||||
@@ -444,7 +481,7 @@ func (p *Planner) addLanguage(
|
||||
// addQuality decomposes a legacy quality value into the two axes that replaced
|
||||
// it, writing up to two rows.
|
||||
func (p *Planner) addQuality(
|
||||
res *Result, sourceTable, identity string,
|
||||
res *Result, sourceTable string, identity json.RawMessage,
|
||||
scope settingscontract.Scope, base Row, raw, columnDefault string,
|
||||
) {
|
||||
value := strings.TrimSpace(raw)
|
||||
@@ -477,7 +514,7 @@ func (p *Planner) addQuality(
|
||||
// addValue appends a row after checking the value against its own definition,
|
||||
// so nothing reaches storage that the mutation endpoint would refuse.
|
||||
func (p *Planner) addValue(
|
||||
res *Result, sourceTable, identity, key string,
|
||||
res *Result, sourceTable string, identity json.RawMessage, key string,
|
||||
scope settingscontract.Scope, base Row, value json.RawMessage,
|
||||
) {
|
||||
def, ok := p.contract.Lookup(key)
|
||||
|
||||
@@ -406,3 +406,34 @@ func TestEveryPlannedRowValidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRejectIdentityIsAlwaysValidJSON. Both backends store this column as JSON —
|
||||
// Postgres declares it jsonb NOT NULL, SQLite guards it with a json_valid CHECK
|
||||
// — so a reject carrying prose would fail to insert, and the migration would
|
||||
// abort on the very rows it exists to record.
|
||||
func TestRejectIdentityIsAlwaysValidJSON(t *testing.T) {
|
||||
res := planner(t).Plan(Input{
|
||||
Profiles: []LegacyProfile{{ID: "p1", Language: str("!!!")}},
|
||||
Settings: []LegacySetting{{Key: "totally.unknown", Value: "x"}},
|
||||
DeviceSettings: []LegacyDeviceSetting{
|
||||
{ProfileID: "p1", DeviceID: "d1", Key: "playback.auto_skip_intro", Value: "maybe"},
|
||||
},
|
||||
SeriesPrefs: []LegacySeriesPreference{
|
||||
{ProfileID: "p1", SeriesID: "s1", SubtitleLanguage: str("!!!")},
|
||||
},
|
||||
LibraryPrefs: []LegacyLibraryPreference{
|
||||
{ProfileID: "p1", LibraryID: 4, AudioLanguage: str("!!!")},
|
||||
},
|
||||
})
|
||||
|
||||
if len(res.Rejects) == 0 {
|
||||
t.Fatal("nothing was rejected, so this proves nothing")
|
||||
}
|
||||
for _, reject := range res.Rejects {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(reject.Identity, &decoded); err != nil {
|
||||
t.Errorf("identity %q for %s is not a JSON object: %v",
|
||||
reject.Identity, reject.SourceKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const schemaVersion = 14
|
||||
const schemaVersion = 15
|
||||
|
||||
func runMigrations(db *sql.DB) error {
|
||||
version, err := userVersion(db)
|
||||
@@ -142,9 +142,27 @@ func runMigrations(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if version < 15 {
|
||||
if err := migrateToV15(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("PRAGMA user_version = 15"); err != nil {
|
||||
return fmt.Errorf("setting sqlite user_version 15: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// migrateToV15 backfills canonical setting values from the legacy tables.
|
||||
//
|
||||
// V14 created the tables; this fills them. It is the cutover's data half, and
|
||||
// it runs in the same transaction as every other step, so a database either
|
||||
// comes out fully migrated or untouched.
|
||||
func migrateToV15(tx *sql.Tx) error {
|
||||
return migrateSettingsToCanonical(tx)
|
||||
}
|
||||
|
||||
// migrateToV14 adds the canonical settings contract tables. InitSchema already
|
||||
// creates them with IF NOT EXISTS on every open, so this step is what records
|
||||
// that an existing database has them — the same shape migrateToV6 used for
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
|
||||
)
|
||||
|
||||
// migrateSettingsToCanonical is the one-time backfill from legacy settings
|
||||
// storage into user_setting_values.
|
||||
//
|
||||
// The conversion rules live in internal/settingsmigrate so this backend and
|
||||
// Postgres cannot disagree about them; everything here is reading rows, handing
|
||||
// them over, and writing what comes back. It runs inside the caller's
|
||||
// transaction, so a failure anywhere leaves the database exactly as it was —
|
||||
// the design's "completes and verifies atomically or leaves the database
|
||||
// unchanged".
|
||||
//
|
||||
// The legacy tables are left in place. Dropping them belongs to the follow-up
|
||||
// migration named in the design's post-cutover cleanup, once migrated counts
|
||||
// have been verified against a backup.
|
||||
func migrateSettingsToCanonical(tx *sql.Tx) error {
|
||||
contract, err := settingscontract.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading settings contract: %w", err)
|
||||
}
|
||||
|
||||
input, err := readLegacySettings(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas())
|
||||
result := planner.Plan(input)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
for _, row := range result.Rows {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO user_setting_values
|
||||
(key, scope, profile_id, device_id, library_id, series_id, value, revision, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
row.Key, string(row.Scope),
|
||||
nullableText(row.ProfileID), nullableText(row.DeviceID),
|
||||
nullableInt(row.LibraryID), nullableText(row.SeriesID),
|
||||
string(row.Value), now, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("writing migrated setting %s at %s: %w", row.Key, row.Scope, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, reject := range result.Rejects {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO user_setting_migration_rejects
|
||||
(source_table, source_key, identity, value, reason, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
reject.SourceTable, reject.SourceKey, string(reject.Identity),
|
||||
reject.Value, reject.Reason, now,
|
||||
); err != nil {
|
||||
return fmt.Errorf("recording migration reject for %s: %w", reject.SourceKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLegacySettings gathers every source the migration reads.
|
||||
//
|
||||
// Each query tolerates a missing table: this runs against databases created at
|
||||
// any schema version, and a table an older install never had is simply empty
|
||||
// rather than fatal.
|
||||
func readLegacySettings(tx *sql.Tx) (settingsmigrate.Input, error) {
|
||||
var input settingsmigrate.Input
|
||||
|
||||
profiles, err := readLegacyProfiles(tx)
|
||||
if err != nil {
|
||||
return input, err
|
||||
}
|
||||
input.Profiles = profiles
|
||||
|
||||
if err := eachRow(tx, `SELECT key, value FROM user_settings`,
|
||||
func(scan func(...any) error) error {
|
||||
var row settingsmigrate.LegacySetting
|
||||
if err := scan(&row.Key, &row.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
input.Settings = append(input.Settings, row)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return input, fmt.Errorf("reading user_settings: %w", err)
|
||||
}
|
||||
|
||||
if err := eachRow(tx, `SELECT profile_id, device_id, key, value FROM user_device_settings`,
|
||||
func(scan func(...any) error) error {
|
||||
var row settingsmigrate.LegacyDeviceSetting
|
||||
if err := scan(&row.ProfileID, &row.DeviceID, &row.Key, &row.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
input.DeviceSettings = append(input.DeviceSettings, row)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return input, fmt.Errorf("reading user_device_settings: %w", err)
|
||||
}
|
||||
|
||||
// Subtitle and audio preferences are two tables keyed the same way, so they
|
||||
// merge into one per-series record rather than producing two rows that
|
||||
// would each overwrite the other's scope.
|
||||
bySeries := map[[2]string]*settingsmigrate.LegacySeriesPreference{}
|
||||
seriesRecord := func(profileID, seriesID string) *settingsmigrate.LegacySeriesPreference {
|
||||
key := [2]string{profileID, seriesID}
|
||||
if existing, ok := bySeries[key]; ok {
|
||||
return existing
|
||||
}
|
||||
record := &settingsmigrate.LegacySeriesPreference{ProfileID: profileID, SeriesID: seriesID}
|
||||
bySeries[key] = record
|
||||
return record
|
||||
}
|
||||
|
||||
if err := eachRow(tx, `
|
||||
SELECT profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles
|
||||
FROM subtitle_preferences`,
|
||||
func(scan func(...any) error) error {
|
||||
var profileID, seriesID string
|
||||
var language, mode sql.NullString
|
||||
var forced sql.NullBool
|
||||
if err := scan(&profileID, &seriesID, &language, &mode, &forced); err != nil {
|
||||
return err
|
||||
}
|
||||
record := seriesRecord(profileID, seriesID)
|
||||
record.SubtitleLanguage = nullString(language)
|
||||
record.SubtitleMode = nullString(mode)
|
||||
record.ShowForcedSubtitles = nullBool(forced)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return input, fmt.Errorf("reading subtitle_preferences: %w", err)
|
||||
}
|
||||
|
||||
if err := eachRow(tx, `SELECT profile_id, series_id, audio_language FROM audio_preferences`,
|
||||
func(scan func(...any) error) error {
|
||||
var profileID, seriesID string
|
||||
var language sql.NullString
|
||||
if err := scan(&profileID, &seriesID, &language); err != nil {
|
||||
return err
|
||||
}
|
||||
seriesRecord(profileID, seriesID).AudioLanguage = nullString(language)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return input, fmt.Errorf("reading audio_preferences: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range bySeries {
|
||||
input.SeriesPrefs = append(input.SeriesPrefs, *record)
|
||||
}
|
||||
|
||||
if err := eachRow(tx, `
|
||||
SELECT profile_id, library_id, audio_language, subtitle_language, subtitle_mode, show_forced_subtitles
|
||||
FROM library_playback_preferences`,
|
||||
func(scan func(...any) error) error {
|
||||
var row settingsmigrate.LegacyLibraryPreference
|
||||
var audio, subtitle, mode sql.NullString
|
||||
var forced sql.NullBool
|
||||
if err := scan(&row.ProfileID, &row.LibraryID,
|
||||
&audio, &subtitle, &mode, &forced); err != nil {
|
||||
return err
|
||||
}
|
||||
row.AudioLanguage = nullString(audio)
|
||||
row.SubtitleLanguage = nullString(subtitle)
|
||||
row.SubtitleMode = nullString(mode)
|
||||
row.ShowForcedSubtitles = nullBool(forced)
|
||||
input.LibraryPrefs = append(input.LibraryPrefs, row)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return input, fmt.Errorf("reading library_playback_preferences: %w", err)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// readLegacyProfiles reads the preference columns off the profiles table.
|
||||
//
|
||||
// preferred_metadata_language is deliberately absent: the column exists only in
|
||||
// the Postgres schema, so catalog.metadata_language has no SQLite source and
|
||||
// the field stays nil here.
|
||||
func readLegacyProfiles(tx *sql.Tx) ([]settingsmigrate.LegacyProfile, error) {
|
||||
var profiles []settingsmigrate.LegacyProfile
|
||||
err := eachRow(tx, `
|
||||
SELECT id, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles
|
||||
FROM profiles`,
|
||||
func(scan func(...any) error) error {
|
||||
var profile settingsmigrate.LegacyProfile
|
||||
var quality, language, subtitle, mode sql.NullString
|
||||
var forced sql.NullBool
|
||||
if err := scan(&profile.ID, &quality, &language, &subtitle, &mode, &forced); err != nil {
|
||||
return err
|
||||
}
|
||||
profile.QualityPreference = nullString(quality)
|
||||
profile.Language = nullString(language)
|
||||
profile.SubtitleLanguage = nullString(subtitle)
|
||||
profile.SubtitleMode = nullString(mode)
|
||||
profile.ShowForcedSubtitles = nullBool(forced)
|
||||
profiles = append(profiles, profile)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading profiles: %w", err)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// eachRow runs a query and calls fn per row, treating a missing table as no
|
||||
// rows. Every legacy table this migration reads was added at some schema
|
||||
// version, so a database older than that simply has nothing to migrate from it.
|
||||
func eachRow(tx *sql.Tx, query string, fn func(scan func(...any) error) error) error {
|
||||
rows, err := tx.Query(query)
|
||||
if err != nil {
|
||||
if isMissingTable(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // read-only iteration
|
||||
|
||||
for rows.Next() {
|
||||
if err := fn(rows.Scan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func isMissingTable(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no such table")
|
||||
}
|
||||
|
||||
func nullString(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
text := value.String
|
||||
return &text
|
||||
}
|
||||
|
||||
func nullBool(value sql.NullBool) *bool {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
flag := value.Bool
|
||||
return &flag
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedLegacySettings fills the pre-cutover tables the way a real install would.
|
||||
func seedLegacySettings(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
// Two profiles on one account: appearance moved from the account to the
|
||||
// profile, so an account row has to reach both.
|
||||
for _, profile := range []struct {
|
||||
id, quality, language, subtitleLang, mode string
|
||||
forced bool
|
||||
}{
|
||||
{"p1", "1080p-high", "ja", "en", "always", false},
|
||||
{"p2", "1080p", "en", "", "auto", true},
|
||||
} {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO profiles
|
||||
(id, name, quality_preference, language, subtitle_language, subtitle_mode, show_forced_subtitles,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
|
||||
profile.id, profile.id, profile.quality, profile.language,
|
||||
profile.subtitleLang, profile.mode, profile.forced); err != nil {
|
||||
t.Fatalf("seeding profile %s: %v", profile.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range map[string]string{
|
||||
"ui_theme": "cobalt-studio",
|
||||
"ui_text_scale": "large",
|
||||
// Rides the same table under a synthetic key and is not a user setting.
|
||||
"jellycompat:displayprefs:usersettings:emby": `{"a":1}`,
|
||||
// Never had a definition; must be recorded rather than dropped.
|
||||
"legacy.mystery": "whatever",
|
||||
} {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO user_settings (key, value) VALUES (?, ?)`, key, value); err != nil {
|
||||
t.Fatalf("seeding user_settings %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range []struct{ profile, device, key, value string }{
|
||||
{"p1", "d1", "playback.preferred_quality", "720p-high"},
|
||||
{"p1", "d1", "playback.auto_skip_intro", "true"},
|
||||
{"p1", "d1", "player.audio_sync_ms", "-250"},
|
||||
} {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO user_device_settings (profile_id, device_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, '2026-01-01T00:00:00Z')`,
|
||||
row.profile, row.device, row.key, row.value); err != nil {
|
||||
t.Fatalf("seeding device setting %s: %v", row.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO subtitle_preferences (profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles, updated_at)
|
||||
VALUES ('p1', 's1', 'de', 'always', 0, '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("seeding subtitle_preferences: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO audio_preferences (profile_id, series_id, audio_language, updated_at)
|
||||
VALUES ('p1', 's1', 'fr', '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("seeding audio_preferences: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO library_playback_preferences (profile_id, library_id, audio_language, subtitle_mode, updated_at)
|
||||
VALUES ('p1', 7, 'es', 'off', '2026-01-01T00:00:00Z')`); err != nil {
|
||||
t.Fatalf("seeding library_playback_preferences: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalValue(t *testing.T, db *sql.DB, key, scope string, where string, args ...any) (string, bool) {
|
||||
t.Helper()
|
||||
query := `SELECT value FROM user_setting_values WHERE key = ? AND scope = ?`
|
||||
if where != "" {
|
||||
query += " AND " + where
|
||||
}
|
||||
full := append([]any{key, scope}, args...)
|
||||
var value string
|
||||
err := db.QueryRow(query, full...).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s at %s: %v", key, scope, err)
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// TestMigrateToV15BackfillsCanonicalValues runs the real migration against a
|
||||
// real database. The planner's rules are unit-tested in internal/settingsmigrate;
|
||||
// what this covers is the wiring — that the rows actually land, satisfy the
|
||||
// scope CHECK and the partial unique indexes, and that nothing violates the
|
||||
// json_valid constraints.
|
||||
func TestMigrateToV15BackfillsCanonicalValues(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
seedLegacySettings(t, db)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if err := migrateToV15(tx); err != nil {
|
||||
t.Fatalf("migrateToV15: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
t.Run("profile columns become profile-scope values", func(t *testing.T) {
|
||||
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile",
|
||||
"profile_id = ?", "p1"); !ok || got != `"ja"` {
|
||||
t.Errorf("p1 audio language = %q (found=%v), want \"ja\"", got, ok)
|
||||
}
|
||||
// p2 holds only column defaults, so it must produce nothing.
|
||||
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile",
|
||||
"profile_id = ?", "p2"); ok {
|
||||
t.Errorf("p2 got %q from a column still holding its default", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy quality decomposes into two axes", func(t *testing.T) {
|
||||
quality, ok := canonicalValue(t, db, "playback.preferred_quality", "profile",
|
||||
"profile_id = ?", "p1")
|
||||
if !ok || quality != `"1080p"` {
|
||||
t.Errorf("resolution = %q (found=%v), want \"1080p\"", quality, ok)
|
||||
}
|
||||
bitrate, ok := canonicalValue(t, db, "playback.max_bitrate_kbps", "profile",
|
||||
"profile_id = ?", "p1")
|
||||
if !ok || bitrate != `10000` {
|
||||
t.Errorf("bitrate = %q (found=%v), want 10000", bitrate, ok)
|
||||
}
|
||||
|
||||
// The device row decomposes too, at its own scope.
|
||||
deviceQuality, ok := canonicalValue(t, db, "playback.preferred_quality", "profile_device",
|
||||
"profile_id = ? AND device_id = ?", "p1", "d1")
|
||||
if !ok || deviceQuality != `"720p"` {
|
||||
t.Errorf("device resolution = %q (found=%v), want \"720p\"", deviceQuality, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("account settings fan out to every profile", func(t *testing.T) {
|
||||
for _, profile := range []string{"p1", "p2"} {
|
||||
if got, ok := canonicalValue(t, db, "ui.theme", "profile",
|
||||
"profile_id = ?", profile); !ok || got != `"cobalt-studio"` {
|
||||
t.Errorf("%s theme = %q (found=%v)", profile, got, ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("series and library preferences land at their scopes", func(t *testing.T) {
|
||||
if got, ok := canonicalValue(t, db, "playback.subtitle_language", "profile_series",
|
||||
"profile_id = ? AND series_id = ?", "p1", "s1"); !ok || got != `"de"` {
|
||||
t.Errorf("series subtitle language = %q (found=%v), want \"de\"", got, ok)
|
||||
}
|
||||
// Audio and subtitle preferences are separate tables keyed alike; both
|
||||
// must survive rather than one overwriting the other.
|
||||
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile_series",
|
||||
"profile_id = ? AND series_id = ?", "p1", "s1"); !ok || got != `"fr"` {
|
||||
t.Errorf("series audio language = %q (found=%v), want \"fr\"", got, ok)
|
||||
}
|
||||
if got, ok := canonicalValue(t, db, "playback.audio_language", "profile_library",
|
||||
"profile_id = ? AND library_id = ?", "p1", 7); !ok || got != `"es"` {
|
||||
t.Errorf("library audio language = %q (found=%v), want \"es\"", got, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy strings become typed JSON", func(t *testing.T) {
|
||||
if got, ok := canonicalValue(t, db, "playback.auto_skip_intro", "profile_device",
|
||||
"profile_id = ? AND device_id = ?", "p1", "d1"); !ok || got != `true` {
|
||||
t.Errorf("auto_skip_intro = %q, want the boolean true", got)
|
||||
}
|
||||
if got, ok := canonicalValue(t, db, "player.audio_sync_ms", "profile_device",
|
||||
"profile_id = ? AND device_id = ?", "p1", "d1"); !ok || got != `-250` {
|
||||
t.Errorf("audio_sync_ms = %q, want the number -250", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unconvertible rows are recorded, jellycompat blobs are not", func(t *testing.T) {
|
||||
var reason, identity string
|
||||
err := db.QueryRow(`
|
||||
SELECT reason, identity FROM user_setting_migration_rejects WHERE source_key = 'legacy.mystery'`).
|
||||
Scan(&reason, &identity)
|
||||
if err != nil {
|
||||
t.Fatalf("the unknown key was dropped rather than recorded: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(identity), &decoded); err != nil {
|
||||
t.Errorf("reject identity %q is not JSON: %v", identity, err)
|
||||
}
|
||||
|
||||
var jellycompat int
|
||||
if err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM user_setting_migration_rejects WHERE source_key LIKE 'jellycompat:%'`).
|
||||
Scan(&jellycompat); err != nil {
|
||||
t.Fatalf("counting jellycompat rejects: %v", err)
|
||||
}
|
||||
if jellycompat != 0 {
|
||||
t.Errorf("%d jellycompat blobs were rejected; they should be left alone", jellycompat)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every written value is valid JSON at an allowed scope", func(t *testing.T) {
|
||||
rows, err := db.Query(`SELECT key, scope, value FROM user_setting_values`)
|
||||
if err != nil {
|
||||
t.Fatalf("listing values: %v", err)
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck // test cleanup
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var key, scope, value string
|
||||
if err := rows.Scan(&key, &scope, &value); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
count++
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
|
||||
t.Errorf("%s at %s holds invalid JSON %q", key, scope, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterating: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("the migration wrote nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigrateToV15IsAtomic. The migration runs inside the caller's transaction,
|
||||
// so a failure has to leave the database exactly as it was rather than half
|
||||
// converted — an operator's restore point is the pre-upgrade backup, and a
|
||||
// partial migration is the one state neither backup nor rollback covers.
|
||||
func TestMigrateToV15IsAtomic(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
seedLegacySettings(t, db)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if err := migrateToV15(tx); err != nil {
|
||||
t.Fatalf("migrateToV15: %v", err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("rollback: %v", err)
|
||||
}
|
||||
|
||||
var values, rejects int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM user_setting_values`).Scan(&values); err != nil {
|
||||
t.Fatalf("counting values: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM user_setting_migration_rejects`).Scan(&rejects); err != nil {
|
||||
t.Fatalf("counting rejects: %v", err)
|
||||
}
|
||||
if values != 0 || rejects != 0 {
|
||||
t.Errorf("rollback left %d values and %d rejects behind", values, rejects)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateToV15OnAnEmptyDatabase: a fresh install has nothing to migrate and
|
||||
// must not fail trying.
|
||||
func TestMigrateToV15OnAnEmptyDatabase(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if err := migrateToV15(tx); err != nil {
|
||||
t.Fatalf("migrateToV15 on an empty database: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateToV15IsIdempotentUnderReRun guards the partial-unique indexes: the
|
||||
// migration must not be runnable twice into a conflict. runMigrations gates it
|
||||
// behind user_version, so the second call is what an operator would trigger by
|
||||
// restoring a backup over a migrated database.
|
||||
func TestMigrateToV15IsIdempotentUnderReRun(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
seedLegacySettings(t, db)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
if err := migrateToV15(tx); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit: %v", err)
|
||||
}
|
||||
|
||||
// A second run collides with the partial unique indexes. That it fails is
|
||||
// correct — silently doubling every value would be worse — but it must fail
|
||||
// as an error rather than corrupting anything, and the version gate in
|
||||
// runMigrations is what stops it happening in practice.
|
||||
tx2, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
err = migrateToV15(tx2)
|
||||
_ = tx2.Rollback()
|
||||
if err == nil {
|
||||
t.Error("a second migration run was accepted; values would be duplicated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user