HASH b15d5b8d9c9d
DATE 2025-12-25
SUBJECT ookstats: cleanup player ranking duplication
FILES 3 CHANGED
HASH b15d5b8d9c9d
DATE 2025-12-25
SUBJECT ookstats: cleanup player ranking
duplication
FILES 3 CHANGED
┌─ nix/pkgs/ookstats/src/cmd/cleanup.go ─────────────────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/cleanup.go b/nix/pkgs/ookstats/src/cmd/clea │
│ nup.go │
│ new file mode 100644 │
│ index 0000000..fd979c9 │
│ --- /dev/null │
│ +++ b/nix/pkgs/ookstats/src/cmd/cleanup.go │
│ @@ -0,0 +1,150 @@ │
│ +package cmd │
│ + │
│ +import ( │
│ + "database/sql" │
│ + "fmt" │
│ + "time" │
│ + │
│ + "github.com/charmbracelet/log" │
│ + "github.com/spf13/cobra" │
│ + "ookstats/internal/database" │
│ +) │
│ + │
│ +var cleanupCmd = &cobra.Command{ │
│ + Use: "cleanup", │
│ + Short: "Database cleanup and maintenance operations", │
│ + Long: `Perform various database cleanup and maintenance operations to optimize │
│ space and performance.`, │
│ +} │
│ + │
│ +var cleanupPlayerRankingsCmd = &cobra.Command{ │
│ + Use: "player-rankings", │
│ + Short: "Remove duplicate player_rankings entries", │
│ + Long: `Removes duplicate player_rankings entries, keeping only the most recent r │
│ anking │
│ +for each (player_id, ranking_type, ranking_scope) combination. │
│ + │
│ +This cleanup is safe to run and will significantly reduce database size if duplic │
│ ates exist.`, │
│ + RunE: func(cmd *cobra.Command, args []string) error { │
│ + dryRun, _ := cmd.Flags().GetBool("dry-run") │
│ + vacuum, _ := cmd.Flags().GetBool("vacuum") │
│ + │
│ + db, err := database.Connect() │
│ + if err != nil { │
│ + return fmt.Errorf("db connect: %w", err) │
│ + } │
│ + defer db.Close() │
│ + │
│ + return runCleanupPlayerRankings(db, dryRun, vacuum) │
│ + }, │
│ +} │
│ + │
│ +func init() { │
│ + rootCmd.AddCommand(cleanupCmd) │
│ + cleanupCmd.AddCommand(cleanupPlayerRankingsCmd) │
│ + │
│ + cleanupPlayerRankingsCmd.Flags().Bool("dry-run", false, "Show what would be dele │
│ ted without actually deleting") │
│ + cleanupPlayerRankingsCmd.Flags().Bool("vacuum", true, "Run VACUUM after cleanup │
│ to reclaim space") │
│ +} │
│ + │
│ +func runCleanupPlayerRankings(db *sql.DB, dryRun bool, vacuum bool) error { │
│ + log.Info("analyzing player_rankings table") │
│ + │
│ + // count total rows │
│ + var totalRows int64 │
│ + if err := db.QueryRow("SELECT COUNT(*) FROM player_rankings").Scan(&totalRows); │
│ err != nil { │
│ + return fmt.Errorf("count total rows: %w", err) │
│ + } │
│ + log.Info("current state", "total_rows", totalRows) │
│ + │
│ + // Count unique combinations │
│ + var uniqueCombos int64 │
│ + if err := db.QueryRow(` │
│ + SELECT COUNT(DISTINCT player_id || '|' || ranking_type || '|' || ranking_scope) │
│ + FROM player_rankings │
│ + `).Scan(&uniqueCombos); err != nil { │
│ + return fmt.Errorf("count unique combos: %w", err) │
│ + } │
│ + │
│ + duplicateRows := totalRows - uniqueCombos │
│ + log.Info("duplicate analysis", │
│ + "unique_combinations", uniqueCombos, │
│ + "duplicate_rows", duplicateRows, │
│ + "duplicate_percentage", fmt.Sprintf("%.1f%%", float64(duplicateRows)/float64(to │
│ talRows)*100)) │
│ + │
│ + if duplicateRows == 0 { │
│ + log.Info("no duplicates found - table is clean!") │
│ + return nil │
│ + } │
│ + │
│ + // count exact duplicates (same player, type, scope, AND rank) │
│ + var exactDuplicates int64 │
│ + if err := db.QueryRow(` │
│ + SELECT COUNT(*) FROM ( │
│ + SELECT player_id, ranking_type, ranking_scope, ranking, COUNT(*) as cnt │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, ranking_scope, ranking │
│ + HAVING cnt > 1 │
│ + ) │
│ + `).Scan(&exactDuplicates); err != nil { │
│ + return fmt.Errorf("count exact duplicates: %w", err) │
│ + } │
│ + if exactDuplicates > 0 { │
│ + log.Warn("exact duplicates found", │
│ + "count", exactDuplicates, │
│ + "note", "same player/type/scope/rank appearing multiple times") │
│ + } │
│ + │
│ + if dryRun { │
│ + log.Info("[DRY RUN] would delete", "rows", duplicateRows) │
│ + log.Info("[DRY RUN] would keep", "rows", uniqueCombos) │
│ + estimatedSizeMB := float64(duplicateRows) * 100 / 1024 / 1024 // rough estimate │
│ + log.Info("[DRY RUN] estimated space savings", "mb", fmt.Sprintf("%.2f MB", esti │
│ matedSizeMB)) │
│ + return nil │
│ + } │
│ + │
│ + log.Info("starting cleanup - this may take a few minutes...") │
│ + startTime := time.Now() │
│ + │
│ + // delete duplicates, keeping only the row with the highest rowid (most recent) │
│ + result, err := db.Exec(` │
│ + DELETE FROM player_rankings │
│ + WHERE rowid NOT IN ( │
│ + SELECT MAX(rowid) │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, ranking_scope │
│ + ) │
│ + `) │
│ + if err != nil { │
│ + return fmt.Errorf("delete duplicates: %w", err) │
│ + } │
│ + │
│ + rowsDeleted, _ := result.RowsAffected() │
│ + duration := time.Since(startTime) │
│ + log.Info("cleanup complete", │
│ + "rows_deleted", rowsDeleted, │
│ + "duration", duration.Round(time.Millisecond)) │
│ + │
│ + // verify cleanup │
│ + var newTotal int64 │
│ + if err := db.QueryRow("SELECT COUNT(*) FROM player_rankings").Scan(&newTotal); e │
│ rr != nil { │
│ + return fmt.Errorf("count after cleanup: %w", err) │
│ + } │
│ + log.Info("verification", │
│ + "rows_before", totalRows, │
│ + "rows_after", newTotal, │
│ + "rows_removed", totalRows-newTotal) │
│ + │
│ + if vacuum { │
│ + log.Info("running VACUUM to reclaim disk space...") │
│ + vacuumStart := time.Now() │
│ + if _, err := db.Exec("VACUUM"); err != nil { │
│ + log.Warn("vacuum failed", "error", err) │
│ + } else { │
│ + log.Info("vacuum complete", "duration", time.Since(vacuumStart).Round(time.Mil │
│ lisecond)) │
│ + } │
│ + } else { │
│ + log.Info("skipping VACUUM (use --vacuum to reclaim disk space)") │
│ + } │
│ + │
│ + log.Info("[SUCCESS] player_rankings cleanup completed successfully!") │
│ + return nil │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...s/ookstats/src/cmd/cleanup.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/cmd/clean │
│ up.go b/nix/pkgs/ookstats/src/cmd/cleanup.go │
│ new file mode 100644 │
│ index 0000000..fd979c9 │
│ --- /dev/null │
│ +++ b/nix/pkgs/ookstats/src/cmd/cleanup.go │
│ @@ -0,0 +1,150 @@ │
│ +package cmd │
│ + │
│ +import ( │
│ + "database/sql" │
│ + "fmt" │
│ + "time" │
│ + │
│ + "github.com/charmbracelet/log" │
│ + "github.com/spf13/cobra" │
│ + "ookstats/internal/database" │
│ +) │
│ + │
│ +var cleanupCmd = &cobra.Command{ │
│ + Use: "cleanup", │
│ + Short: "Database cleanup and maintenance o │
│ perations", │
│ + Long: `Perform various database cleanup a │
│ nd maintenance operations to optimize space │
│ and performance.`, │
│ +} │
│ + │
│ +var cleanupPlayerRankingsCmd = &cobra.Comma │
│ nd{ │
│ + Use: "player-rankings", │
│ + Short: "Remove duplicate player_rankings e │
│ ntries", │
│ + Long: `Removes duplicate player_rankings e │
│ ntries, keeping only the most recent ranking │
│ +for each (player_id, ranking_type, ranking_ │
│ scope) combination. │
│ + │
│ +This cleanup is safe to run and will signif │
│ icantly reduce database size if duplicates e │
│ xist.`, │
│ + RunE: func(cmd *cobra.Command, args []stri │
│ ng) error { │
│ + dryRun, _ := cmd.Flags().GetBool("dry-run │
│ ") │
│ + vacuum, _ := cmd.Flags().GetBool("vacuum" │
│ ) │
│ + │
│ + db, err := database.Connect() │
│ + if err != nil { │
│ + return fmt.Errorf("db connect: %w", err) │
│ + } │
│ + defer db.Close() │
│ + │
│ + return runCleanupPlayerRankings(db, dryRu │
│ n, vacuum) │
│ + }, │
│ +} │
│ + │
│ +func init() { │
│ + rootCmd.AddCommand(cleanupCmd) │
│ + cleanupCmd.AddCommand(cleanupPlayerRanking │
│ sCmd) │
│ + │
│ + cleanupPlayerRankingsCmd.Flags().Bool("dry │
│ -run", false, "Show what would be deleted wi │
│ thout actually deleting") │
│ + cleanupPlayerRankingsCmd.Flags().Bool("vac │
│ uum", true, "Run VACUUM after cleanup to rec │
│ laim space") │
│ +} │
│ + │
│ +func runCleanupPlayerRankings(db *sql.DB, d │
│ ryRun bool, vacuum bool) error { │
│ + log.Info("analyzing player_rankings table" │
│ ) │
│ + │
│ + // count total rows │
│ + var totalRows int64 │
│ + if err := db.QueryRow("SELECT COUNT(*) FRO │
│ M player_rankings").Scan(&totalRows); err != │
│ nil { │
│ + return fmt.Errorf("count total rows: %w", │
│ err) │
│ + } │
│ + log.Info("current state", "total_rows", to │
│ talRows) │
│ + │
│ + // Count unique combinations │
│ + var uniqueCombos int64 │
│ + if err := db.QueryRow(` │
│ + SELECT COUNT(DISTINCT player_id || '|' || │
│ ranking_type || '|' || ranking_scope) │
│ + FROM player_rankings │
│ + `).Scan(&uniqueCombos); err != nil { │
│ + return fmt.Errorf("count unique combos: % │
│ w", err) │
│ + } │
│ + │
│ + duplicateRows := totalRows - uniqueCombos │
│ + log.Info("duplicate analysis", │
│ + "unique_combinations", uniqueCombos, │
│ + "duplicate_rows", duplicateRows, │
│ + "duplicate_percentage", fmt.Sprintf("%.1f │
│ %%", float64(duplicateRows)/float64(totalRow │
│ s)*100)) │
│ + │
│ + if duplicateRows == 0 { │
│ + log.Info("no duplicates found - table is │
│ clean!") │
│ + return nil │
│ + } │
│ + │
│ + // count exact duplicates (same player, ty │
│ pe, scope, AND rank) │
│ + var exactDuplicates int64 │
│ + if err := db.QueryRow(` │
│ + SELECT COUNT(*) FROM ( │
│ + SELECT player_id, ranking_type, ranking_ │
│ scope, ranking, COUNT(*) as cnt │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, rankin │
│ g_scope, ranking │
│ + HAVING cnt > 1 │
│ + ) │
│ + `).Scan(&exactDuplicates); err != nil { │
│ + return fmt.Errorf("count exact duplicates │
│ : %w", err) │
│ + } │
│ + if exactDuplicates > 0 { │
│ + log.Warn("exact duplicates found", │
│ + "count", exactDuplicates, │
│ + "note", "same player/type/scope/rank app │
│ earing multiple times") │
│ + } │
│ + │
│ + if dryRun { │
│ + log.Info("[DRY RUN] would delete", "rows" │
│ , duplicateRows) │
│ + log.Info("[DRY RUN] would keep", "rows", │
│ uniqueCombos) │
│ + estimatedSizeMB := float64(duplicateRows) │
│ * 100 / 1024 / 1024 // rough estimate │
│ + log.Info("[DRY RUN] estimated space savin │
│ gs", "mb", fmt.Sprintf("%.2f MB", estimatedS │
│ izeMB)) │
│ + return nil │
│ + } │
│ + │
│ + log.Info("starting cleanup - this may take │
│ a few minutes...") │
│ + startTime := time.Now() │
│ + │
│ + // delete duplicates, keeping only the row │
│ with the highest rowid (most recent) │
│ + result, err := db.Exec(` │
│ + DELETE FROM player_rankings │
│ + WHERE rowid NOT IN ( │
│ + SELECT MAX(rowid) │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, rankin │
│ g_scope │
│ + ) │
│ + `) │
│ + if err != nil { │
│ + return fmt.Errorf("delete duplicates: %w" │
│ , err) │
│ + } │
│ + │
│ + rowsDeleted, _ := result.RowsAffected() │
│ + duration := time.Since(startTime) │
│ + log.Info("cleanup complete", │
│ + "rows_deleted", rowsDeleted, │
│ + "duration", duration.Round(time.Milliseco │
│ nd)) │
│ + │
│ + // verify cleanup │
│ + var newTotal int64 │
│ + if err := db.QueryRow("SELECT COUNT(*) FRO │
│ M player_rankings").Scan(&newTotal); err != │
│ nil { │
│ + return fmt.Errorf("count after cleanup: % │
│ w", err) │
│ + } │
│ + log.Info("verification", │
│ + "rows_before", totalRows, │
│ + "rows_after", newTotal, │
│ + "rows_removed", totalRows-newTotal) │
│ + │
│ + if vacuum { │
│ + log.Info("running VACUUM to reclaim disk │
│ space...") │
│ + vacuumStart := time.Now() │
│ + if _, err := db.Exec("VACUUM"); err != ni │
│ l { │
│ + log.Warn("vacuum failed", "error", err) │
│ + } else { │
│ + log.Info("vacuum complete", "duration", │
│ time.Since(vacuumStart).Round(time.Milliseco │
│ nd)) │
│ + } │
│ + } else { │
│ + log.Info("skipping VACUUM (use --vacuum t │
│ o reclaim disk space)") │
│ + } │
│ + │
│ + log.Info("[SUCCESS] player_rankings cleanu │
│ p completed successfully!") │
│ + return nil │
│ +} │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/database/client.go ────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/database/client.go b/nix/pkgs/ookstats │
│ /src/internal/database/client.go │
│ index 34821d0..3ff3b44 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/database/client.go │
│ +++ b/nix/pkgs/ookstats/src/internal/database/client.go │
│ @@ -242,12 +242,13 @@ func EnsureCompleteSchema(db *sql.DB) error { │
│ )`, │
│ │
│ `CREATE TABLE IF NOT EXISTS player_rankings ( │
│ - player_id INTEGER, │
│ - ranking_type TEXT, │
│ - ranking_scope TEXT, │
│ + player_id INTEGER NOT NULL, │
│ + ranking_type TEXT NOT NULL, │
│ + ranking_scope TEXT NOT NULL, │
│ ranking INTEGER, │
│ combined_best_time INTEGER, │
│ - last_updated INTEGER │
│ + last_updated INTEGER, │
│ + PRIMARY KEY (player_id, ranking_type, ranking_scope) │
│ )`, │
│ │
│ `CREATE TABLE IF NOT EXISTS player_seasonal_rankings ( │
│ @@ -403,6 +404,11 @@ func EnsureCompleteSchema(db *sql.DB) error { │
│ return err │
│ } │
│ │
│ + // Migrate player_rankings to add PRIMARY KEY constraint │
│ + if err := migratePlayerRankingsPrimaryKey(db); err != nil { │
│ + return err │
│ + } │
│ + │
│ // Create indexes │
│ return ensureRecommendedIndexes(db) │
│ } │
│ @@ -435,6 +441,9 @@ func ensureRecommendedIndexes(db *sql.DB) error { │
│ "CREATE INDEX IF NOT EXISTS idx_player_best_runs_season ON player_best_runs(sea │
│ son_id, player_id)", │
│ "CREATE INDEX IF NOT EXISTS idx_player_profiles_season ON player_profiles(seaso │
│ n_id, global_ranking)", │
│ "CREATE INDEX IF NOT EXISTS idx_player_profiles_season_coverage ON player_profi │
│ les(season_id, has_complete_coverage, combined_best_time)", │
│ + // Player rankings indexes │
│ + "CREATE INDEX IF NOT EXISTS idx_player_rankings_scope ON player_rankings(rankin │
│ g_type, ranking_scope)", │
│ + "CREATE INDEX IF NOT EXISTS idx_player_rankings_player ON player_rankings(playe │
│ r_id)", │
│ } │
│ for _, s := range stmts { │
│ if _, err := db.Exec(s); err != nil { │
│ @@ -641,3 +650,80 @@ func columnExists(db *sql.DB, table, column string) (bool, er │
│ ror) { │
│ } │
│ return false, rows.Err() │
│ } │
│ + │
│ +// migratePlayerRankingsPrimaryKey adds PRIMARY KEY constraint to player_rankings │
│ table if missing │
│ +func migratePlayerRankingsPrimaryKey(db *sql.DB) error { │
│ + // Check if table already has PRIMARY KEY by inspecting schema │
│ + var createSQL string │
│ + err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='p │
│ layer_rankings'`).Scan(&createSQL) │
│ + if err != nil { │
│ + // Table doesn't exist yet, skip migration │
│ + return nil │
│ + } │
│ + │
│ + // If PRIMARY KEY already exists, skip migration │
│ + if strings.Contains(strings.ToUpper(createSQL), "PRIMARY KEY") { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Migrating player_rankings table to add PRIMARY KEY constraint...\n") │
│ + │
│ + // Begin transaction for safe migration │
│ + tx, err := db.Begin() │
│ + if err != nil { │
│ + return fmt.Errorf("begin transaction: %w", err) │
│ + } │
│ + defer tx.Rollback() │
│ + │
│ + // Create new table with PRIMARY KEY │
│ + if _, err := tx.Exec(` │
│ + CREATE TABLE player_rankings_new ( │
│ + player_id INTEGER NOT NULL, │
│ + ranking_type TEXT NOT NULL, │
│ + ranking_scope TEXT NOT NULL, │
│ + ranking INTEGER, │
│ + combined_best_time INTEGER, │
│ + last_updated INTEGER, │
│ + PRIMARY KEY (player_id, ranking_type, ranking_scope) │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("create new table: %w", err) │
│ + } │
│ + │
│ + // Copy data, keeping only the most recent entry per (player_id, ranking_type, r │
│ anking_scope) │
│ + if _, err := tx.Exec(` │
│ + INSERT INTO player_rankings_new │
│ + SELECT │
│ + player_id, │
│ + ranking_type, │
│ + ranking_scope, │
│ + ranking, │
│ + combined_best_time, │
│ + last_updated │
│ + FROM player_rankings │
│ + WHERE rowid IN ( │
│ + SELECT MAX(rowid) │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, ranking_scope │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("copy data: %w", err) │
│ + } │
│ + │
│ + // Drop old table │
│ + if _, err := tx.Exec(`DROP TABLE player_rankings`); err != nil { │
│ + return fmt.Errorf("drop old table: %w", err) │
│ + } │
│ + │
│ + // Rename new table │
│ + if _, err := tx.Exec(`ALTER TABLE player_rankings_new RENAME TO player_rankings` │
│ ); err != nil { │
│ + return fmt.Errorf("rename new table: %w", err) │
│ + } │
│ + │
│ + if err := tx.Commit(); err != nil { │
│ + return fmt.Errorf("commit transaction: %w", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] player_rankings table migrated - added PRIMARY KEY and removed │
│ duplicates\n") │
│ + return nil │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/internal/database/client.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ database/client.go b/nix/pkgs/ookstats/src/i │
│ nternal/database/client.go │
│ index 34821d0..3ff3b44 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/databas │
│ e/client.go │
│ +++ b/nix/pkgs/ookstats/src/internal/databas │
│ e/client.go │
│ @@ -242,12 +242,13 @@ func EnsureCompleteSch │
│ ema(db *sql.DB) error { │
│ )`, │
│ │
│ `CREATE TABLE IF NOT EXISTS player_rankin │
│ gs ( │
│ - player_id INTEGER, │
│ - ranking_type TEXT, │
│ - ranking_scope TEXT, │
│ + player_id INTEGER NOT NULL, │
│ + ranking_type TEXT NOT NULL, │
│ + ranking_scope TEXT NOT NULL, │
│ ranking INTEGER, │
│ combined_best_time INTEGER, │
│ - last_updated INTEGER │
│ + last_updated INTEGER, │
│ + PRIMARY KEY (player_id, ranking_type, ra │
│ nking_scope) │
│ )`, │
│ │
│ `CREATE TABLE IF NOT EXISTS player_season │
│ al_rankings ( │
│ @@ -403,6 +404,11 @@ func EnsureCompleteSche │
│ ma(db *sql.DB) error { │
│ return err │
│ } │
│ │
│ + // Migrate player_rankings to add PRIMARY │
│ KEY constraint │
│ + if err := migratePlayerRankingsPrimaryKey( │
│ db); err != nil { │
│ + return err │
│ + } │
│ + │
│ // Create indexes │
│ return ensureRecommendedIndexes(db) │
│ } │
│ @@ -435,6 +441,9 @@ func ensureRecommendedIn │
│ dexes(db *sql.DB) error { │
│ "CREATE INDEX IF NOT EXISTS idx_player_be │
│ st_runs_season ON player_best_runs(season_id │
│ , player_id)", │
│ "CREATE INDEX IF NOT EXISTS idx_player_pr │
│ ofiles_season ON player_profiles(season_id, │
│ global_ranking)", │
│ "CREATE INDEX IF NOT EXISTS idx_player_pr │
│ ofiles_season_coverage ON player_profiles(se │
│ ason_id, has_complete_coverage, combined_bes │
│ t_time)", │
│ + // Player rankings indexes │
│ + "CREATE INDEX IF NOT EXISTS idx_player_ra │
│ nkings_scope ON player_rankings(ranking_type │
│ , ranking_scope)", │
│ + "CREATE INDEX IF NOT EXISTS idx_player_ra │
│ nkings_player ON player_rankings(player_id)" │
│ , │
│ } │
│ for _, s := range stmts { │
│ if _, err := db.Exec(s); err != nil { │
│ @@ -641,3 +650,80 @@ func columnExists(db *s │
│ ql.DB, table, column string) (bool, error) { │
│ } │
│ return false, rows.Err() │
│ } │
│ + │
│ +// migratePlayerRankingsPrimaryKey adds PRI │
│ MARY KEY constraint to player_rankings table │
│ if missing │
│ +func migratePlayerRankingsPrimaryKey(db *sq │
│ l.DB) error { │
│ + // Check if table already has PRIMARY KEY │
│ by inspecting schema │
│ + var createSQL string │
│ + err := db.QueryRow(`SELECT sql FROM sqlite │
│ _master WHERE type='table' AND name='player_ │
│ rankings'`).Scan(&createSQL) │
│ + if err != nil { │
│ + // Table doesn't exist yet, skip migratio │
│ n │
│ + return nil │
│ + } │
│ + │
│ + // If PRIMARY KEY already exists, skip mig │
│ ration │
│ + if strings.Contains(strings.ToUpper(create │
│ SQL), "PRIMARY KEY") { │
│ + return nil │
│ + } │
│ + │
│ + fmt.Printf("Migrating player_rankings tabl │
│ e to add PRIMARY KEY constraint...\n") │
│ + │
│ + // Begin transaction for safe migration │
│ + tx, err := db.Begin() │
│ + if err != nil { │
│ + return fmt.Errorf("begin transaction: %w" │
│ , err) │
│ + } │
│ + defer tx.Rollback() │
│ + │
│ + // Create new table with PRIMARY KEY │
│ + if _, err := tx.Exec(` │
│ + CREATE TABLE player_rankings_new ( │
│ + player_id INTEGER NOT NULL, │
│ + ranking_type TEXT NOT NULL, │
│ + ranking_scope TEXT NOT NULL, │
│ + ranking INTEGER, │
│ + combined_best_time INTEGER, │
│ + last_updated INTEGER, │
│ + PRIMARY KEY (player_id, ranking_type, ra │
│ nking_scope) │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("create new table: %w", │
│ err) │
│ + } │
│ + │
│ + // Copy data, keeping only the most recent │
│ entry per (player_id, ranking_type, ranking │
│ _scope) │
│ + if _, err := tx.Exec(` │
│ + INSERT INTO player_rankings_new │
│ + SELECT │
│ + player_id, │
│ + ranking_type, │
│ + ranking_scope, │
│ + ranking, │
│ + combined_best_time, │
│ + last_updated │
│ + FROM player_rankings │
│ + WHERE rowid IN ( │
│ + SELECT MAX(rowid) │
│ + FROM player_rankings │
│ + GROUP BY player_id, ranking_type, rankin │
│ g_scope │
│ + ) │
│ + `); err != nil { │
│ + return fmt.Errorf("copy data: %w", err) │
│ + } │
│ + │
│ + // Drop old table │
│ + if _, err := tx.Exec(`DROP TABLE player_ra │
│ nkings`); err != nil { │
│ + return fmt.Errorf("drop old table: %w", e │
│ rr) │
│ + } │
│ + │
│ + // Rename new table │
│ + if _, err := tx.Exec(`ALTER TABLE player_r │
│ ankings_new RENAME TO player_rankings`); err │
│ != nil { │
│ + return fmt.Errorf("rename new table: %w", │
│ err) │
│ + } │
│ + │
│ + if err := tx.Commit(); err != nil { │
│ + return fmt.Errorf("commit transaction: %w │
│ ", err) │
│ + } │
│ + │
│ + fmt.Printf("[OK] player_rankings table mig │
│ rated - added PRIMARY KEY and removed duplic │
│ ates\n") │
│ + return nil │
│ +} │
└──────────────────────────────────────────────┘
┌─ nix/pkgs/ookstats/src/internal/pipeline/process.go ───────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/pipeline/process.go b/nix/pkgs/ookstat │
│ s/src/internal/pipeline/process.go │
│ index 89a7455..b3feee8 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/pipeline/process.go │
│ +++ b/nix/pkgs/ookstats/src/internal/pipeline/process.go │
│ @@ -417,7 +417,7 @@ func computePlayerRankings(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, ranking_type, ranking_scope, ranking, │
│ combined_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings (player_id, ranking_type, ranking_scope │
│ , ranking, combined_best_time, last_updated) │
│ SELECT │
│ player_id, 'best_overall', 'global', global_ranking, combined_best_time, ? │
│ FROM player_profiles │
│ @@ -473,7 +473,7 @@ func computePlayerRankings(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, ranking_type, ranking_scope, ranking, │
│ combined_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings (player_id, ranking_type, ranking_scope │
│ , ranking, combined_best_time, last_updated) │
│ SELECT │
│ pp.player_id, 'best_overall', r.region, pp.regional_ranking, pp.combined_best │
│ _time, ? │
│ FROM player_profiles pp │
│ @@ -548,7 +548,7 @@ func computePlayerRankings(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, ranking_type, ranking_scope, ranking, │
│ combined_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings (player_id, ranking_type, ranking_scope │
│ , ranking, combined_best_time, last_updated) │
│ SELECT │
│ player_id, 'best_overall', CAST(realm_id AS TEXT), realm_ranking, combined_be │
│ st_time, ? │
│ FROM player_profiles │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../internal/pipeline/process.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ pipeline/process.go b/nix/pkgs/ookstats/src/ │
│ internal/pipeline/process.go │
│ index 89a7455..b3feee8 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/pipelin │
│ e/process.go │
│ +++ b/nix/pkgs/ookstats/src/internal/pipelin │
│ e/process.go │
│ @@ -417,7 +417,7 @@ func computePlayerRankin │
│ gs(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, │
│ ranking_type, ranking_scope, ranking, combin │
│ ed_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings ( │
│ player_id, ranking_type, ranking_scope, rank │
│ ing, combined_best_time, last_updated) │
│ SELECT │
│ player_id, 'best_overall', 'global', gl │
│ obal_ranking, combined_best_time, ? │
│ FROM player_profiles │
│ @@ -473,7 +473,7 @@ func computePlayerRankin │
│ gs(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, │
│ ranking_type, ranking_scope, ranking, combin │
│ ed_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings ( │
│ player_id, ranking_type, ranking_scope, rank │
│ ing, combined_best_time, last_updated) │
│ SELECT │
│ pp.player_id, 'best_overall', r.region, │
│ pp.regional_ranking, pp.combined_best_time, │
│ ? │
│ FROM player_profiles pp │
│ @@ -548,7 +548,7 @@ func computePlayerRankin │
│ gs(tx *sql.Tx) (int, error) { │
│ } │
│ │
│ _, err = tx.Exec(` │
│ - INSERT INTO player_rankings (player_id, │
│ ranking_type, ranking_scope, ranking, combin │
│ ed_best_time, last_updated) │
│ + INSERT OR REPLACE INTO player_rankings ( │
│ player_id, ranking_type, ranking_scope, rank │
│ ing, combined_best_time, last_updated) │
│ SELECT │
│ player_id, 'best_overall', CAST(realm_i │
│ d AS TEXT), realm_ranking, combined_best_tim │
│ e, ? │
│ FROM player_profiles │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET