Files

203 lines
7.6 KiB
Go
Raw Permalink Normal View History

package database
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
"github.com/Silo-Server/silo-server/internal/jellycompat/displayprefs"
)
// displayPrefsMoveVersion sorts immediately after
// 20260728132326_jellycompat_displayprefs.sql, which creates the table this
// fills — the same pairing user_setting_values uses with the settings
// backfill.
const displayPrefsMoveVersion int64 = 20260728132327
// displayPrefsMoveMigration rehomes the Jellyfin DisplayPreferences blobs from
// user_settings (jellycompat:* keys) into jellycompat_displayprefs, removing
// the last non-settings tenant of the legacy key/value table.
//
// A Go migration rather than SQL because the key parsing and row
// classification live in internal/jellycompat/displayprefs, shared with the
// per-user SQLite backend so the two cannot diverge; SQL could not use those
// rules without duplicating them. Values are copied byte-for-byte — the blobs
// are opaque Jellyfin client JSON and reinterpreting them is not this
// migration's business.
//
// RunTx, so a store comes out fully moved or untouched. Re-running is
// harmless: the up deletes every jellycompat:* source row, so a second pass
// finds nothing, and ON CONFLICT DO NOTHING keeps even a mixed state (a backup
// restored over a migrated database) from failing or clobbering the
// already-moved value.
func displayPrefsMoveMigration() *goose.Migration {
return goose.NewGoMigration(
displayPrefsMoveVersion,
&goose.GoFunc{RunTx: moveDisplayPrefs},
&goose.GoFunc{RunTx: unmoveDisplayPrefs},
)
}
// moveDisplayPrefs copies every user's jellycompat rows over, then removes
// them from user_settings. A jellycompat:* row that does not parse as a
// DisplayPreferences key could only have been written through the legacy
// settings API's unknown-key carve-out, which this cutover removes; those rows
// are recorded in user_setting_migration_rejects for operator inspection
// rather than silently deleted.
//
// Every delete names the exact (user_id, key, value) triple this transaction
// read, never the key pattern. Under READ COMMITTED each statement takes its
// own snapshot, so a wider delete would also catch a row an old-binary app
// instance committed between the SELECT and the DELETE during a rolling
// deploy — an insert under a new key or an update to a row already read —
// destroying the newer value without ever copying it. Pinning the value makes
// such a row survive as a stranded legacy row, which a re-run picks up.
func moveDisplayPrefs(ctx context.Context, tx *sql.Tx) error {
type legacyRow struct {
userID int
key, value string
}
var legacy []legacyRow
if err := eachRow(ctx, tx,
`SELECT user_id, key, value FROM user_settings WHERE key LIKE $1`,
func(scan func(...any) error) error {
var row legacyRow
if err := scan(&row.userID, &row.key, &row.value); err != nil {
return err
}
legacy = append(legacy, row)
return nil
}, displayprefs.LegacyKeyPattern()); err != nil {
return fmt.Errorf("reading jellycompat rows from user_settings: %w", err)
}
for _, row := range legacy {
blob, reject := displayprefs.PlanLegacyRow(row.key, row.value)
if blob != nil {
if _, err := tx.ExecContext(ctx, `
INSERT INTO jellycompat_displayprefs (user_id, prefs_id, client, value)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, prefs_id, client) DO NOTHING`,
row.userID, blob.PrefsID, blob.Client, blob.Value,
); err != nil {
return fmt.Errorf("moving display prefs %q for user %d: %w",
row.key, row.userID, err)
}
} else if _, err := tx.ExecContext(ctx, `
INSERT INTO user_setting_migration_rejects
(user_id, source_table, source_key, identity, value, reason)
VALUES ($1, 'user_settings', $2, '{"scope":"account"}'::jsonb, $3, $4)`,
row.userID, reject.Key, reject.Value, reject.Reason,
); err != nil {
return fmt.Errorf("recording displayprefs reject %q for user %d: %w",
reject.Key, row.userID, err)
}
// Deleted only once its copy or reject insert succeeded, and only the
// exact row this transaction read — see the function comment.
if _, err := tx.ExecContext(ctx,
`DELETE FROM user_settings WHERE user_id = $1 AND key = $2 AND value = $3`,
row.userID, row.key, row.value,
); err != nil {
return fmt.Errorf("deleting jellycompat row %q for user %d: %w",
row.key, row.userID, err)
}
}
return nil
}
// unmoveDisplayPrefs is the inverse: unlike the settings backfill, the up
// migration deletes its source rows, so rolling back has to write them back.
// Moved blobs reconstruct their legacy key through the shared rules; rejected
// rows restore from the audit table. Both sides then discard what the up
// migration wrote, so the follow-up table drop removes nothing that is not
// already back in user_settings.
//
// The same read-then-scoped-delete shape as moveDisplayPrefs: under READ
// COMMITTED a blanket delete sees rows committed after this transaction's
// reads (a new-binary instance still serving DisplayPreferences writes into
// jellycompat_displayprefs during the rollback window) and would drop them
// without restoring them.
func unmoveDisplayPrefs(ctx context.Context, tx *sql.Tx) error {
type movedRow struct {
userID int
prefsID, client, value string
}
var moved []movedRow
if err := eachRow(ctx, tx,
`SELECT user_id, prefs_id, client, value FROM jellycompat_displayprefs`,
func(scan func(...any) error) error {
var row movedRow
if err := scan(&row.userID, &row.prefsID, &row.client, &row.value); err != nil {
return err
}
moved = append(moved, row)
return nil
}); err != nil {
return fmt.Errorf("reading jellycompat_displayprefs: %w", err)
}
for _, row := range moved {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_settings (user_id, key, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO NOTHING`,
row.userID, displayprefs.LegacyKey(row.prefsID, row.client), row.value,
); err != nil {
return fmt.Errorf("restoring display prefs %q/%q for user %d: %w",
row.prefsID, row.client, row.userID, err)
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM jellycompat_displayprefs
WHERE user_id = $1 AND prefs_id = $2 AND client = $3 AND value = $4`,
row.userID, row.prefsID, row.client, row.value,
); err != nil {
return fmt.Errorf("clearing moved display prefs %q/%q for user %d: %w",
row.prefsID, row.client, row.userID, err)
}
}
// Rejects restore by primary key for the same reason: only migrations
// write this table, but the audit trail must never lose a row it did not
// just put back.
type rejectRow struct {
id int64
userID int
key string
value string
}
var rejects []rejectRow
if err := eachRow(ctx, tx, `
SELECT id, user_id, source_key, COALESCE(value, '')
FROM user_setting_migration_rejects
WHERE source_table = 'user_settings' AND source_key LIKE $1`,
func(scan func(...any) error) error {
var row rejectRow
if err := scan(&row.id, &row.userID, &row.key, &row.value); err != nil {
return err
}
rejects = append(rejects, row)
return nil
}, displayprefs.LegacyKeyPattern()); err != nil {
return fmt.Errorf("reading displayprefs rejects: %w", err)
}
for _, row := range rejects {
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_settings (user_id, key, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO NOTHING`,
row.userID, row.key, row.value,
); err != nil {
return fmt.Errorf("restoring rejected jellycompat row %q for user %d: %w",
row.key, row.userID, err)
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM user_setting_migration_rejects WHERE id = $1`, row.id,
); err != nil {
return fmt.Errorf("clearing displayprefs reject %d: %w", row.id, err)
}
}
return nil
}