Files
silo-server/internal/database/settings_backfill.go
T
QuickandClaude Opus 5 cfbd69b240 feat(settings): run the one-time migration on the Postgres backend
The mirror of userdb V15, registered with goose as a Go migration rather
than SQL: the conversion validates every value against its own definition
and re-encodes it as typed JSON, and one legacy quality string becomes two
rows — neither is expressible in SQL without duplicating the manifest. The
rules stay in internal/settingsmigrate, so the two backends cannot disagree.

RunTx, so the whole backfill lands in goose's transaction. The down
migration empties the canonical tables; the legacy ones are never touched
by the up, which is what keeps the cutover reversible until the follow-up
migration drops the superseded columns.

preferred_metadata_language is read here and only here — the column exists
in this schema and not in SQLite's, so this is the sole source for
catalog.metadata_language.

Verified against a real Postgres: the full goose chain runs, 1080p-high
decomposes to ("1080p", 10000), values land as typed jsonb rather than
strings (jsonb_typeof reports number), rejects carry a queryable jsonb
identity, and the composite profile foreign key refuses a row naming a
profile that does not exist.

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

319 lines
10 KiB
Go

package database
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingsmigrate"
)
// settingsBackfillVersion is the timestamp version this backfill occupies. It
// sorts immediately after 20260727010621_user_setting_values.sql, which creates
// the tables this fills.
const settingsBackfillVersion int64 = 20260727010622
// settingsBackfillMigration is the one-time conversion of legacy settings
// storage into user_setting_values.
//
// A Go migration rather than SQL because the conversion is not expressible in
// SQL without duplicating the contract: every value has to be validated against
// its own definition and re-encoded as typed JSON, and a legacy quality string
// decomposes into two rows. Those rules live in internal/settingsmigrate so
// this and the SQLite backend cannot disagree; this file reads rows, hands them
// over, and writes what comes back.
//
// RunTx, so the whole backfill lands in goose's transaction — a partial
// migration is the one state neither an operator's backup nor a rollback
// covers.
func settingsBackfillMigration() *goose.Migration {
return goose.NewGoMigration(
settingsBackfillVersion,
&goose.GoFunc{RunTx: backfillSettingValues},
&goose.GoFunc{RunTx: rollbackSettingValues},
)
}
// backfillSettingValues converts every user's legacy settings.
func backfillSettingValues(ctx context.Context, tx *sql.Tx) error {
contract, err := settingscontract.Load()
if err != nil {
return fmt.Errorf("loading settings contract: %w", err)
}
planner := settingsmigrate.New(contract, settingscontract.ObjectSchemas())
userIDs, err := settingsBackfillUserIDs(ctx, tx)
if err != nil {
return err
}
for _, userID := range userIDs {
input, err := readLegacySettingsForUser(ctx, tx, userID)
if err != nil {
return fmt.Errorf("reading legacy settings for user %d: %w", userID, err)
}
result := planner.Plan(input)
for _, row := range result.Rows {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_setting_values
(user_id, key, scope, profile_id, device_id, library_id, series_id, value)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
userID, row.Key, string(row.Scope),
nullText(row.ProfileID), nullText(row.DeviceID),
nullInt(row.LibraryID), nullText(row.SeriesID),
string(row.Value),
); err != nil {
return fmt.Errorf("writing %s at %s for user %d: %w",
row.Key, row.Scope, userID, err)
}
}
for _, reject := range result.Rejects {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_setting_migration_rejects
(user_id, source_table, source_key, identity, value, reason)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)`,
userID, reject.SourceTable, reject.SourceKey,
string(reject.Identity), reject.Value, reject.Reason,
); err != nil {
return fmt.Errorf("recording reject %s for user %d: %w",
reject.SourceKey, userID, err)
}
}
}
return nil
}
// rollbackSettingValues empties the canonical tables.
//
// The legacy tables are never modified by the up migration, so undoing it is
// simply discarding what was derived from them. This is what makes the cutover
// reversible before the follow-up migration drops the legacy columns.
func rollbackSettingValues(ctx context.Context, tx *sql.Tx) error {
for _, table := range []string{
"user_setting_values",
"user_setting_migration_rejects",
} {
if _, err := tx.ExecContext(ctx, "DELETE FROM "+table); err != nil {
return fmt.Errorf("clearing %s: %w", table, err)
}
}
return nil
}
// settingsBackfillUserIDs returns every user with something to migrate.
//
// A user with no settings and no profiles produces nothing, so they are skipped
// rather than queried five times each.
func settingsBackfillUserIDs(ctx context.Context, tx *sql.Tx) ([]int, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id FROM users
WHERE EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id = users.id)
OR EXISTS (SELECT 1 FROM user_settings s WHERE s.user_id = users.id)
ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("listing users: %w", err)
}
defer rows.Close() //nolint:errcheck // read-only iteration
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scanning user id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// readLegacySettingsForUser gathers one user's legacy rows.
func readLegacySettingsForUser(
ctx context.Context, tx *sql.Tx, userID int,
) (settingsmigrate.Input, error) {
var input settingsmigrate.Input
// Profiles. preferred_metadata_language exists only in this schema — the
// SQLite profiles table never had the column — so this is the sole source
// for catalog.metadata_language.
if err := eachRow(ctx, tx, `
SELECT id, quality_preference, language, subtitle_language, subtitle_mode,
show_forced_subtitles, preferred_metadata_language
FROM user_profiles WHERE user_id = $1`,
func(scan func(...any) error) error {
var profile settingsmigrate.LegacyProfile
var quality, language, subtitle, mode, metadata sql.NullString
var forced sql.NullBool
if err := scan(&profile.ID, &quality, &language, &subtitle,
&mode, &forced, &metadata); err != nil {
return err
}
profile.QualityPreference = nullableString(quality)
profile.Language = nullableString(language)
profile.SubtitleLanguage = nullableString(subtitle)
profile.SubtitleMode = nullableString(mode)
profile.ShowForcedSubtitles = nullableBool(forced)
profile.PreferredMetadataLanguage = nullableString(metadata)
input.Profiles = append(input.Profiles, profile)
return nil
}, userID); err != nil {
return input, fmt.Errorf("reading user_profiles: %w", err)
}
if err := eachRow(ctx, tx,
`SELECT key, value FROM user_settings WHERE user_id = $1`,
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
}, userID); err != nil {
return input, fmt.Errorf("reading user_settings: %w", err)
}
if err := eachRow(ctx, tx, `
SELECT profile_id, device_id, key, value
FROM user_device_settings WHERE user_id = $1`,
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
}, userID); err != nil {
return input, fmt.Errorf("reading user_device_settings: %w", err)
}
// Subtitle and audio preferences are keyed alike, so they merge into one
// per-series record; converting them independently would produce two rows
// racing for the same identity.
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(ctx, tx, `
SELECT profile_id, series_id, subtitle_language, subtitle_mode, show_forced_subtitles
FROM user_subtitle_preferences WHERE user_id = $1`,
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 = nullableString(language)
record.SubtitleMode = nullableString(mode)
record.ShowForcedSubtitles = nullableBool(forced)
return nil
}, userID); err != nil {
return input, fmt.Errorf("reading user_subtitle_preferences: %w", err)
}
if err := eachRow(ctx, tx, `
SELECT profile_id, series_id, audio_language
FROM user_audio_preferences WHERE user_id = $1`,
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 = nullableString(language)
return nil
}, userID); err != nil {
return input, fmt.Errorf("reading user_audio_preferences: %w", err)
}
for _, record := range bySeries {
input.SeriesPrefs = append(input.SeriesPrefs, *record)
}
if err := eachRow(ctx, tx, `
SELECT profile_id, library_id, audio_language, subtitle_language, subtitle_mode,
show_forced_subtitles
FROM user_library_playback_preferences WHERE user_id = $1`,
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 = nullableString(audio)
row.SubtitleLanguage = nullableString(subtitle)
row.SubtitleMode = nullableString(mode)
row.ShowForcedSubtitles = nullableBool(forced)
input.LibraryPrefs = append(input.LibraryPrefs, row)
return nil
}, userID); err != nil {
return input, fmt.Errorf("reading user_library_playback_preferences: %w", err)
}
return input, nil
}
func eachRow(
ctx context.Context, tx *sql.Tx, query string,
fn func(scan func(...any) error) error, args ...any,
) error {
rows, err := tx.QueryContext(ctx, query, args...)
if err != 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 nullableString(value sql.NullString) *string {
if !value.Valid {
return nil
}
text := value.String
return &text
}
func nullableBool(value sql.NullBool) *bool {
if !value.Valid {
return nil
}
flag := value.Bool
return &flag
}
func nullText(value string) any {
if value == "" {
return nil
}
return value
}
func nullInt(value int) any {
if value == 0 {
return nil
}
return value
}