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>
253 lines
8.1 KiB
Go
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
|
|
}
|