203 lines
7.6 KiB
Go
203 lines
7.6 KiB
Go
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
|
||
|
|
}
|