Files
silo-server/internal/userdb/setting_values_migrate.go
T
QuickandClaude Opus 5 1f40e25438 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>
2026-07-27 23:18:18 +00:00

253 lines
8.1 KiB
Go

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
}