┌─ nix/pkgs/ookstats/src/internal/database/operations.go ────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/database/operations.go b/nix/pkgs/ooks │
│ tats/src/internal/database/operations.go │
│ index 227c093..9b79a32 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/database/operations.go │
│ +++ b/nix/pkgs/ookstats/src/internal/database/operations.go │
│ @@ -256,7 +256,30 @@ func (ds *DatabaseService) InsertLeaderboardData(leaderboard │
│ *blizzard.Leaderboa │
│ playerRealmID = realmID // Fallback to run realm │
│ } │
│ │
│ - // insert or ignore player │
│ + // check if player with same name+realm already exists (handles faction transf │
│ ers where Blizzard ID changes) │
│ + existingPlayerID, err := getExistingPlayerIDTx(tx, playerName, playerRealmID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to check existing player: %w", err) │
│ + } │
│ + │
│ + // always use the NEW Blizzard ID as canonical │
│ + effectivePlayerID := playerID │
│ + │
│ + if existingPlayerID != 0 && existingPlayerID != int64(playerID) { │
│ + // player exists with different ID (likely faction transfer) │
│ + // migrate old runs to the new player ID and mark old player invalid │
│ + _, err := tx.Exec(`UPDATE run_members SET player_id = ? WHERE player_id = ?`, │
│ playerID, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to migrate runs to new player ID: %w", err) │
│ + } │
│ + // mark old player as invalid │
│ + _, err = tx.Exec(`UPDATE players SET is_valid = 0 WHERE id = ?`, existingPlay │
│ erID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to invalidate old player: %w", err) │
│ + } │
│ + } │
│ + │
│ + // insert/update player with the new ID │
│ playerQuery := ` │
│ INSERT INTO players (id, blizzard_character_id, name, name_lower, realm_id) │
│ VALUES (?, ?, ?, lower(?), ?) │
│ @@ -301,7 +324,7 @@ func (ds *DatabaseService) InsertLeaderboardData(leaderboard * │
│ blizzard.Leaderboa │
│ factionPtr = &faction │
│ } │
│ │
│ - _, err = tx.Exec(memberQuery, runID, playerID, specPtr, factionPtr) │
│ + _, err = tx.Exec(memberQuery, runID, effectivePlayerID, specPtr, factionPtr) │
│ if err != nil { │
│ return 0, 0, fmt.Errorf("failed to insert run member: %w", err) │
│ } │
│ @@ -722,7 +745,30 @@ func (ds *DatabaseService) insertLeaderboardDataTx(tx *sql.Tx │
│ , leaderboard *bliz │
│ playerRealmID = realmID │
│ } │
│ │
│ - // insert player │
│ + // check if player with same name+realm already exists (handles faction transf │
│ ers where Blizzard ID changes) │
│ + existingPlayerID, err := getExistingPlayerIDTx(tx, playerName, playerRealmID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to check existing player: %w", err) │
│ + } │
│ + │
│ + // always use the NEW Blizzard ID as canonical │
│ + effectivePlayerID := playerID │
│ + │
│ + if existingPlayerID != 0 && existingPlayerID != int64(playerID) { │
│ + // player exists with different ID (likely faction transfer) │
│ + // migrate old runs to the new player ID and mark old player invalid │
│ + _, err := tx.Exec(`UPDATE run_members SET player_id = ? WHERE player_id = ?`, │
│ playerID, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to migrate runs to new player ID: %w", err) │
│ + } │
│ + // mark old player as invalid │
│ + _, err = tx.Exec(`UPDATE players SET is_valid = 0 WHERE id = ?`, existingPlay │
│ erID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to invalidate old player: %w", err) │
│ + } │
│ + } │
│ + │
│ + // insert/update player with the new ID │
│ playerQuery := ` │
│ INSERT INTO players (id, name, name_lower, realm_id) │
│ VALUES (?, ?, lower(?), ?) │
│ @@ -761,7 +807,7 @@ func (ds *DatabaseService) insertLeaderboardDataTx(tx *sql.Tx, │
│ leaderboard *bliz │
│ factionPtr = &faction │
│ } │
│ │
│ - _, err = tx.Exec(memberQuery, runID, playerID, specPtr, factionPtr) │
│ + _, err = tx.Exec(memberQuery, runID, effectivePlayerID, specPtr, factionPtr) │
│ if err != nil { │
│ return 0, 0, fmt.Errorf("failed to insert run member: %w", err) │
│ } │
│ @@ -1875,6 +1921,14 @@ func (ds *DatabaseService) GetAllFingerprintHashes() (map[s │
│ tring]int64, error) { │
│ return result, rows.Err() │
│ } │
│ │
│ +// DeletePlayerFingerprint removes a player's fingerprint record │
│ +func (ds *DatabaseService) DeletePlayerFingerprint(playerID int64) error { │
│ + return retryOnBusy(func() error { │
│ + _, err := ds.db.Exec(`DELETE FROM player_fingerprints WHERE player_id = ?`, pla │
│ yerID) │
│ + return err │
│ + }) │
│ +} │
│ + │
│ // MigratePlayerRuns updates all run_members records from one player to another │
│ func (ds *DatabaseService) MigratePlayerRuns(fromPlayerID, toPlayerID int64) (int │
│ , error) { │
│ var rowsAffected int │
│ @@ -1930,3 +1984,22 @@ func (ds *DatabaseService) GetPlayerByNameRealmRegion(name, │
│ realmSlug, region st │
│ } │
│ return playerID, nil │
│ } │
│ + │
│ +// getExistingPlayerIDTx looks up an existing player by name+realm within a trans │
│ action. │
│ +// Returns 0 if no player found (not an error). │
│ +func getExistingPlayerIDTx(tx *sql.Tx, name string, realmID int) (int64, error) { │
│ + var playerID int64 │
│ + err := tx.QueryRow(` │
│ + SELECT id FROM players │
│ + WHERE name_lower = LOWER(?) AND realm_id = ? │
│ + LIMIT 1 │
│ + `, name, realmID).Scan(&playerID) │
│ + │
│ + if err == sql.ErrNoRows { │
│ + return 0, nil │
│ + } │
│ + if err != nil { │
│ + return 0, err │
│ + } │
│ + return playerID, nil │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...ternal/database/operations.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ database/operations.go b/nix/pkgs/ookstats/s │
│ rc/internal/database/operations.go │
│ index 227c093..9b79a32 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/databas │
│ e/operations.go │
│ +++ b/nix/pkgs/ookstats/src/internal/databas │
│ e/operations.go │
│ @@ -256,7 +256,30 @@ func (ds *DatabaseServi │
│ ce) InsertLeaderboardData(leaderboard *blizz │
│ ard.Leaderboa │
│ playerRealmID = realmID // Fallback to │
│ run realm │
│ } │
│ │
│ - // insert or ignore player │
│ + // check if player with same name+realm │
│ already exists (handles faction transfers wh │
│ ere Blizzard ID changes) │
│ + existingPlayerID, err := getExistingPlay │
│ erIDTx(tx, playerName, playerRealmID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to chec │
│ k existing player: %w", err) │
│ + } │
│ + │
│ + // always use the NEW Blizzard ID as can │
│ onical │
│ + effectivePlayerID := playerID │
│ + │
│ + if existingPlayerID != 0 && existingPlay │
│ erID != int64(playerID) { │
│ + // player exists with different ID (lik │
│ ely faction transfer) │
│ + // migrate old runs to the new player I │
│ D and mark old player invalid │
│ + _, err := tx.Exec(`UPDATE run_members S │
│ ET player_id = ? WHERE player_id = ?`, playe │
│ rID, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to mig │
│ rate runs to new player ID: %w", err) │
│ + } │
│ + // mark old player as invalid │
│ + _, err = tx.Exec(`UPDATE players SET is │
│ _valid = 0 WHERE id = ?`, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to inv │
│ alidate old player: %w", err) │
│ + } │
│ + } │
│ + │
│ + // insert/update player with the new ID │
│ playerQuery := ` │
│ INSERT INTO players (id, blizzard_chara │
│ cter_id, name, name_lower, realm_id) │
│ VALUES (?, ?, ?, lower(?), ?) │
│ @@ -301,7 +324,7 @@ func (ds *DatabaseServic │
│ e) InsertLeaderboardData(leaderboard *blizza │
│ rd.Leaderboa │
│ factionPtr = &faction │
│ } │
│ │
│ - _, err = tx.Exec(memberQuery, runID, pla │
│ yerID, specPtr, factionPtr) │
│ + _, err = tx.Exec(memberQuery, runID, eff │
│ ectivePlayerID, specPtr, factionPtr) │
│ if err != nil { │
│ return 0, 0, fmt.Errorf("failed to inse │
│ rt run member: %w", err) │
│ } │
│ @@ -722,7 +745,30 @@ func (ds *DatabaseServi │
│ ce) insertLeaderboardDataTx(tx *sql.Tx, lead │
│ erboard *bliz │
│ playerRealmID = realmID │
│ } │
│ │
│ - // insert player │
│ + // check if player with same name+realm │
│ already exists (handles faction transfers wh │
│ ere Blizzard ID changes) │
│ + existingPlayerID, err := getExistingPlay │
│ erIDTx(tx, playerName, playerRealmID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to chec │
│ k existing player: %w", err) │
│ + } │
│ + │
│ + // always use the NEW Blizzard ID as can │
│ onical │
│ + effectivePlayerID := playerID │
│ + │
│ + if existingPlayerID != 0 && existingPlay │
│ erID != int64(playerID) { │
│ + // player exists with different ID (lik │
│ ely faction transfer) │
│ + // migrate old runs to the new player I │
│ D and mark old player invalid │
│ + _, err := tx.Exec(`UPDATE run_members S │
│ ET player_id = ? WHERE player_id = ?`, playe │
│ rID, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to mig │
│ rate runs to new player ID: %w", err) │
│ + } │
│ + // mark old player as invalid │
│ + _, err = tx.Exec(`UPDATE players SET is │
│ _valid = 0 WHERE id = ?`, existingPlayerID) │
│ + if err != nil { │
│ + return 0, 0, fmt.Errorf("failed to inv │
│ alidate old player: %w", err) │
│ + } │
│ + } │
│ + │
│ + // insert/update player with the new ID │
│ playerQuery := ` │
│ INSERT INTO players (id, name, name_low │
│ er, realm_id) │
│ VALUES (?, ?, lower(?), ?) │
│ @@ -761,7 +807,7 @@ func (ds *DatabaseServic │
│ e) insertLeaderboardDataTx(tx *sql.Tx, leade │
│ rboard *bliz │
│ factionPtr = &faction │
│ } │
│ │
│ - _, err = tx.Exec(memberQuery, runID, pla │
│ yerID, specPtr, factionPtr) │
│ + _, err = tx.Exec(memberQuery, runID, eff │
│ ectivePlayerID, specPtr, factionPtr) │
│ if err != nil { │
│ return 0, 0, fmt.Errorf("failed to inse │
│ rt run member: %w", err) │
│ } │
│ @@ -1875,6 +1921,14 @@ func (ds *DatabaseSer │
│ vice) GetAllFingerprintHashes() (map[string] │
│ int64, error) { │
│ return result, rows.Err() │
│ } │
│ │
│ +// DeletePlayerFingerprint removes a player │
│ 's fingerprint record │
│ +func (ds *DatabaseService) DeletePlayerFing │
│ erprint(playerID int64) error { │
│ + return retryOnBusy(func() error { │
│ + _, err := ds.db.Exec(`DELETE FROM player_ │
│ fingerprints WHERE player_id = ?`, playerID) │
│ + return err │
│ + }) │
│ +} │
│ + │
│ // MigratePlayerRuns updates all run_member │
│ s records from one player to another │
│ func (ds *DatabaseService) MigratePlayerRun │
│ s(fromPlayerID, toPlayerID int64) (int, erro │
│ r) { │
│ var rowsAffected int │
│ @@ -1930,3 +1984,22 @@ func (ds *DatabaseSer │
│ vice) GetPlayerByNameRealmRegion(name, realm │
│ Slug, region st │
│ } │
│ return playerID, nil │
│ } │
│ + │
│ +// getExistingPlayerIDTx looks up an existi │
│ ng player by name+realm within a transaction │
│ . │
│ +// Returns 0 if no player found (not an err │
│ or). │
│ +func getExistingPlayerIDTx(tx *sql.Tx, name │
│ string, realmID int) (int64, error) { │
│ + var playerID int64 │
│ + err := tx.QueryRow(` │
│ + SELECT id FROM players │
│ + WHERE name_lower = LOWER(?) AND realm_id │
│ = ? │
│ + LIMIT 1 │
│ + `, name, realmID).Scan(&playerID) │
│ + │
│ + if err == sql.ErrNoRows { │
│ + return 0, nil │
│ + } │
│ + if err != nil { │
│ + return 0, err │
│ + } │
│ + return playerID, nil │
│ +} │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go ──────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go b/nix/pkgs/oo │
│ kstats/src/internal/pipeline/fingerprints.go │
│ index f699aab..c71597f 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go │
│ +++ b/nix/pkgs/ookstats/src/internal/pipeline/fingerprints.go │
│ @@ -334,27 +334,38 @@ func processFingerprintCandidate(db *database.DatabaseServic │
│ e, client *blizzard. │
│ │
│ existing := collisionMap[hash] │
│ if existing != 0 && existing != cand.PlayerID { │
│ - // Merge: migrate runs from duplicate ? canonical │
│ - runsMigrated, err := db.MigratePlayerRuns(cand.PlayerID, existing) │
│ + // Merge: the candidate (cand) is the newer player, existing is the old one │
│ + // Migrate runs FROM old (existing) TO new (cand) │
│ + runsMigrated, err := db.MigratePlayerRuns(existing, cand.PlayerID) │
│ if err != nil { │
│ out.err = fmt.Errorf("failed to migrate runs: %w", err) │
│ return out │
│ } │
│ │
│ - // Invalidate canonical player's profile for rebuild │
│ - if err := db.InvalidatePlayerProfile(existing); err != nil { │
│ - logger.Warn("failed to invalidate profile", "player_id", existing, "error", er │
│ r) │
│ + // Mark the old player as invalid │
│ + if err := db.UpdatePlayerStatus(existing, false, nowMillis(), nil); err != nil │
│ { │
│ + logger.Warn("failed to invalidate old player", │
│ + "player_id", existing, │
│ + "error", err) │
│ } │
│ │
│ - logger.Info("merged player identity", │
│ - "duplicate_id", cand.PlayerID, │
│ - "canonical_id", existing, │
│ + // Delete old player's fingerprint so the new one becomes canonical │
│ + if err := db.DeletePlayerFingerprint(existing); err != nil { │
│ + logger.Warn("failed to delete old fingerprint", │
│ + "player_id", existing, │
│ + "error", err) │
│ + } │
│ + │
│ + // Update collision map to point to the new canonical player │
│ + collisionMap[hash] = cand.PlayerID │
│ + │
│ + logger.Info("merged player identity (old ? new)", │
│ + "old_id", existing, │
│ + "new_id", cand.PlayerID, │
│ "runs_migrated", runsMigrated, │
│ "hash", hash[:16]) │
│ │
│ - out.invalid = true │
│ - out.statusValid = false │
│ - return out │
│ + // Continue processing to create fingerprint for the new canonical player │
│ } │
│ │
│ now := nowMillis() │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...rnal/pipeline/fingerprints.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ pipeline/fingerprints.go b/nix/pkgs/ookstats │
│ /src/internal/pipeline/fingerprints.go │
│ index f699aab..c71597f 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/pipelin │
│ e/fingerprints.go │
│ +++ b/nix/pkgs/ookstats/src/internal/pipelin │
│ e/fingerprints.go │
│ @@ -334,27 +334,38 @@ func processFingerprin │
│ tCandidate(db *database.DatabaseService, cli │
│ ent *blizzard. │
│ │
│ existing := collisionMap[hash] │
│ if existing != 0 && existing != cand.Playe │
│ rID { │
│ - // Merge: migrate runs from duplicate ? c │
│ anonical │
│ - runsMigrated, err := db.MigratePlayerRuns │
│ (cand.PlayerID, existing) │
│ + // Merge: the candidate (cand) is the new │
│ er player, existing is the old one │
│ + // Migrate runs FROM old (existing) TO ne │
│ w (cand) │
│ + runsMigrated, err := db.MigratePlayerRuns │
│ (existing, cand.PlayerID) │
│ if err != nil { │
│ out.err = fmt.Errorf("failed to migrate │
│ runs: %w", err) │
│ return out │
│ } │
│ │
│ - // Invalidate canonical player's profile │
│ for rebuild │
│ - if err := db.InvalidatePlayerProfile(exis │
│ ting); err != nil { │
│ - logger.Warn("failed to invalidate profil │
│ e", "player_id", existing, "error", err) │
│ + // Mark the old player as invalid │
│ + if err := db.UpdatePlayerStatus(existing, │
│ false, nowMillis(), nil); err != nil { │
│ + logger.Warn("failed to invalidate old pl │
│ ayer", │
│ + "player_id", existing, │
│ + "error", err) │
│ } │
│ │
│ - logger.Info("merged player identity", │
│ - "duplicate_id", cand.PlayerID, │
│ - "canonical_id", existing, │
│ + // Delete old player's fingerprint so the │
│ new one becomes canonical │
│ + if err := db.DeletePlayerFingerprint(exis │
│ ting); err != nil { │
│ + logger.Warn("failed to delete old finger │
│ print", │
│ + "player_id", existing, │
│ + "error", err) │
│ + } │
│ + │
│ + // Update collision map to point to the n │
│ ew canonical player │
│ + collisionMap[hash] = cand.PlayerID │
│ + │
│ + logger.Info("merged player identity (old │
│ ? new)", │
│ + "old_id", existing, │
│ + "new_id", cand.PlayerID, │
│ "runs_migrated", runsMigrated, │
│ "hash", hash[:16]) │
│ │
│ - out.invalid = true │
│ - out.statusValid = false │
│ - return out │
│ + // Continue processing to create fingerpr │
│ int for the new canonical player │
│ } │
│ │
│ now := nowMillis() │
└──────────────────────────────────────────────┘