Adv 2.0 L4f-prep: DisplayName migration scaffold (schema + dual-write + helper)

Adds player_meta.display_name with idempotent backfill, dual-writes from
createAdvCharacter (in-tx) and saveAdvCharacter (post-commit), and a
loadDisplayName helper with AdvCharacter fallback. Readers are not flipped
yet — soak begins now; per-call-site swap follows in a later session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 09:28:48 -07:00
parent d300008812
commit 5000c3d696
6 changed files with 211 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ type PlayerMeta struct {
ArenaWins int
ArenaLosses int
InvasionScore int
DisplayName string
}
// loadPlayerMeta reads the player_meta row for a user. Returns a
@@ -30,9 +31,9 @@ type PlayerMeta struct {
func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) {
m := &PlayerMeta{UserID: userID}
err := db.Get().QueryRow(
`SELECT arena_wins, arena_losses, invasion_score FROM player_meta WHERE user_id = ?`,
`SELECT arena_wins, arena_losses, invasion_score, display_name FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore)
).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName)
if err == sql.ErrNoRows {
return m, nil
}
@@ -77,3 +78,80 @@ func backfillPlayerMetaArena() error {
slog.Info("player_meta: arena counters backfilled", "rows", n)
return nil
}
// upsertPlayerMetaDisplayName writes display_name for a user, leaving other
// columns untouched. Used by the dual-write path during the L4f-prep
// DisplayName migration: every place that mutates AdvCharacter.DisplayName
// also calls this so player_meta.display_name stays in sync until soak
// completes and readers flip over (gogobee_legacy_migration.md §7).
func upsertPlayerMetaDisplayName(userID id.UserID, displayName string) error {
_, err := db.Get().Exec(
`INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`,
string(userID), displayName,
)
return err
}
// loadDisplayName returns the player's display name from player_meta when
// present, falling back to adventure_characters.display_name during the
// L4f-prep soak window. Empty string if the user has no row in either
// table. Callers should prefer this helper over reading char.DisplayName
// directly so the eventual reader flip is a no-op at the call site.
func loadDisplayName(userID id.UserID) (string, error) {
var name string
err := db.Get().QueryRow(
`SELECT display_name FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&name)
if err == nil && name != "" {
return name, nil
}
if err != nil && err != sql.ErrNoRows {
return "", err
}
// Fallback during soak.
err = db.Get().QueryRow(
`SELECT display_name FROM adventure_characters WHERE user_id = ?`,
string(userID),
).Scan(&name)
if err == sql.ErrNoRows {
return "", nil
}
return name, err
}
// backfillPlayerMetaDisplayName copies adventure_characters.display_name
// into player_meta.display_name for any row whose display_name is still
// the empty default. Idempotent: safe to re-run; only updates rows that
// haven't been populated yet (either by backfill or the dual-write path).
func backfillPlayerMetaDisplayName() error {
// Ensure a player_meta row exists for every adventure_characters user
// (arena backfill already does this, but display_name backfill must
// not assume order — re-run cheaply via INSERT OR IGNORE).
if _, err := db.Get().Exec(`
INSERT OR IGNORE INTO player_meta (user_id)
SELECT user_id FROM adventure_characters
`); err != nil {
return err
}
res, err := db.Get().Exec(`
UPDATE player_meta
SET display_name = (
SELECT display_name FROM adventure_characters
WHERE adventure_characters.user_id = player_meta.user_id
)
WHERE display_name = ''
AND EXISTS (
SELECT 1 FROM adventure_characters
WHERE adventure_characters.user_id = player_meta.user_id
AND adventure_characters.display_name <> ''
)
`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
slog.Info("player_meta: display_name backfilled", "rows", n)
return nil
}