Split The code is public; the app answers only to the crew.
The first paint groups every match ever played: `standings` costs 42 ms at 50k rows and 171 ms at 200k #218
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/184-the-first-paint-groups-every-match-ever → main 1 commit fork
2 files
changed +103
−17
src/db.test.ts +36
| @@ -120,6 +120,32 @@ test("a row written before the map catalogue existed reads as de_forge, and no e | ||
| 120 | 120 | expect(recentMatches(db).find((m) => m.id === "m1")?.map).toBe("de_forge"); |
| 121 | 121 | }); |
| 122 | 122 | |
| 123 | test("a database written before the standing table existed builds it once, from what match already holds", () => { | |
| 124 | const where = dir(); | |
| 125 | const old = new Database(join(where, "app.db")); | |
| 126 | old.exec(`CREATE TABLE match ( | |
| 127 | id TEXT PRIMARY KEY, identity TEXT NOT NULL DEFAULT '', session TEXT NOT NULL, name TEXT NOT NULL, | |
| 128 | map TEXT NOT NULL DEFAULT 'de_forge', | |
| 129 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, | |
| 130 | damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL, | |
| 131 | score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, walked_out INTEGER NOT NULL DEFAULT 0, | |
| 132 | seconds INTEGER NOT NULL, | |
| 133 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 134 | updated_at TEXT NOT NULL DEFAULT (datetime('now')))`); | |
| 135 | old.query( | |
| 136 | `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds) | |
| 137 | VALUES ('m1', 'user:u_1', 'deadbeefdeadbeefdeadbeefdeadbeef', 'Vesper', 9, 4, 3, 1200, 5, 3, 2, 1, 0, 420)`, | |
| 138 | ).run(); | |
| 139 | old.close(); | |
| 140 | ||
| 141 | const db = openDatabase(where); | |
| 142 | const table = standings(db); | |
| 143 | expect(table).toHaveLength(1); | |
| 144 | expect(table[0]?.identity).toBe("user:u_1"); | |
| 145 | expect(table[0]?.kills).toBe(9); | |
| 146 | expect(table[0]?.matches).toBe(1); | |
| 147 | }); | |
| 148 | ||
| 123 | 149 | test("a saved match records the map it was played on", () => { |
| 124 | 150 | const db = openDatabase(dir()); |
| 125 | 151 | expect(saveMatch(db, writer("user:u_1", "ada"), result({ map: "de_forge" }))).toBe(true); |
| @@ -207,6 +233,16 @@ test("a genuinely later post still updates the numbers it grows", () => { | ||
| 207 | 233 | expect(row?.state).toBe("finished"); |
| 208 | 234 | }); |
| 209 | 235 | |
| 236 | test("standings answers in a budget that does not grow with the table: 20,000 matches in under 10ms", () => { | |
| 237 | const db = openDatabase(dir()); | |
| 238 | for (let i = 0; i < 20_000; i++) { | |
| 239 | expect(saveMatch(db, writer(`user:u_${i}`, `player ${i}`), result({ kills: i % 40 }))).toBe(true); | |
| 240 | } | |
| 241 | const start = performance.now(); | |
| 242 | standings(db, 10); | |
| 243 | expect(performance.now() - start).toBeLessThan(10); | |
| 244 | }); | |
| 245 | ||
| 210 | 246 | test("a row nobody ever closed reads as abandoned once it goes stale", () => { |
| 211 | 247 | const db = openDatabase(dir()); |
| 212 | 248 | const stale = result({ finished: false, left: false }); |
src/db.ts +67 −17
| @@ -117,9 +117,62 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 117 | 117 | // for every row this adds it to, and nothing already there is rewritten. |
| 118 | 118 | if (!columns.includes("map")) db.exec("ALTER TABLE match ADD COLUMN map TEXT NOT NULL DEFAULT 'de_forge'"); |
| 119 | 119 | db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)"); |
| 120 | // The board, kept per identity instead of grouped from the whole table on | |
| 121 | // every read: `saveMatch` and `deleteMatch` recompute one identity's row | |
| 122 | // here — cheap, bounded by that identity's own matches — so `standings` | |
| 123 | // below is just a sorted read of a table no bigger than the player count. | |
| 124 | // A database written before this table existed builds it once, the same | |
| 125 | // way `standings` used to compute it, and never again pays that cost. | |
| 126 | const hasStanding = db.query<{ name: string }, []>( | |
| 127 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'standing'", | |
| 128 | ).get() !== null; | |
| 129 | if (!hasStanding) { | |
| 130 | db.exec(`CREATE TABLE standing ( | |
| 131 | identity TEXT PRIMARY KEY, | |
| 132 | name TEXT NOT NULL, | |
| 133 | last TEXT NOT NULL, | |
| 134 | matches INTEGER NOT NULL, | |
| 135 | kills INTEGER NOT NULL, | |
| 136 | deaths INTEGER NOT NULL, | |
| 137 | headshots INTEGER NOT NULL, | |
| 138 | damage INTEGER NOT NULL, | |
| 139 | wins INTEGER NOT NULL, | |
| 140 | rounds INTEGER NOT NULL | |
| 141 | )`); | |
| 142 | db.exec(`INSERT INTO standing (identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 143 | SELECT identity, name, MAX(updated_at), COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage), | |
| 144 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds) | |
| 145 | FROM match WHERE rounds > 0 GROUP BY identity`); | |
| 146 | } | |
| 147 | db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC)"); | |
| 120 | 148 | return db; |
| 121 | 149 | } |
| 122 | 150 | |
| 151 | // What one identity's row in `standing` should read, recomputed from that | |
| 152 | // identity's own matches only — never the whole table — so a save or a | |
| 153 | // delete costs what that one player's history costs, not what everyone's | |
| 154 | // does. This is `standings`' old GROUP BY, narrowed to `WHERE identity = ?1`. | |
| 155 | const STANDING_FOR_IDENTITY_SQL = `SELECT name, MAX(updated_at) AS last, COUNT(*) AS matches, | |
| 156 | SUM(kills) AS kills, SUM(deaths) AS deaths, SUM(headshots) AS headshots, SUM(damage) AS damage, | |
| 157 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, SUM(rounds) AS rounds | |
| 158 | FROM match WHERE identity = ?1 AND rounds > 0`; | |
| 159 | ||
| 160 | function recomputeStanding(db: Database, identity: string): void { | |
| 161 | const row = db.query<Omit<Standing, "identity">, [string]>(STANDING_FOR_IDENTITY_SQL).get(identity); | |
| 162 | if (row === null || row.matches === 0) { | |
| 163 | db.query("DELETE FROM standing WHERE identity = ?1").run(identity); | |
| 164 | return; | |
| 165 | } | |
| 166 | db.query( | |
| 167 | `INSERT INTO standing (identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 168 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) | |
| 169 | ON CONFLICT(identity) DO UPDATE SET | |
| 170 | name = excluded.name, last = excluded.last, matches = excluded.matches, kills = excluded.kills, | |
| 171 | deaths = excluded.deaths, headshots = excluded.headshots, damage = excluded.damage, | |
| 172 | wins = excluded.wins, rounds = excluded.rounds`, | |
| 173 | ).run(identity, row.name, row.last, row.matches, row.kills, row.deaths, row.headshots, row.damage, row.wins, row.rounds); | |
| 174 | } | |
| 175 | ||
| 123 | 176 | /** |
| 124 | 177 | * Write a result, credited to the writer's identity and under the writer's |
| 125 | 178 | * name — never a name the request asked for. A match belongs to the identity |
| @@ -158,6 +211,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 158 | 211 | result.headshots, result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, |
| 159 | 212 | result.left ? 1 : 0, result.seconds, |
| 160 | 213 | ); |
| 214 | recomputeStanding(db, writer.identity); | |
| 161 | 215 | return true; |
| 162 | 216 | } |
| 163 | 217 | |
| @@ -170,24 +224,15 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] => | ||
| 170 | 224 | |
| 171 | 225 | /** |
| 172 | 226 | * The table people actually look at: one row an identity, best first. The name |
| 173 | * shown is the one on that identity's most recent match — SQLite takes a bare | |
| 174 | * column from the row `MAX(updated_at)` picked — so a forge rename follows the | |
| 175 | * player instead of splitting their record in two. | |
| 227 | * shown is the one on that identity's most recent match, kept current by | |
| 228 | * `recomputeStanding` on every write, so a forge rename follows the player | |
| 229 | * instead of splitting their record in two — and this read costs the size of | |
| 230 | * the board, not the size of the match table behind it. | |
| 176 | 231 | */ |
| 177 | 232 | export const standings = (db: Database, limit = 10): Standing[] => |
| 178 | 233 | db.query<Standing, [number]>( |
| 179 | `SELECT identity, | |
| 180 | name, | |
| 181 | MAX(updated_at) AS last, | |
| 182 | COUNT(*) AS matches, | |
| 183 | SUM(kills) AS kills, | |
| 184 | SUM(deaths) AS deaths, | |
| 185 | SUM(headshots) AS headshots, | |
| 186 | SUM(damage) AS damage, | |
| 187 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, | |
| 188 | SUM(rounds) AS rounds | |
| 189 | FROM match WHERE rounds > 0 | |
| 190 | GROUP BY identity | |
| 234 | `SELECT identity, name, last, matches, kills, deaths, headshots, damage, wins, rounds | |
| 235 | FROM standing | |
| 191 | 236 | ORDER BY kills DESC, damage DESC |
| 192 | 237 | LIMIT ?1`, |
| 193 | 238 | ).all(limit); |
| @@ -195,8 +240,13 @@ export const standings = (db: Database, limit = 10): Standing[] => | ||
| 195 | 240 | export const matchCount = (db: Database): number => |
| 196 | 241 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 197 | 242 | |
| 198 | export const deleteMatch = (db: Database, id: string): boolean => | |
| 199 | db.query("DELETE FROM match WHERE id = ?1").run(id).changes > 0; | |
| 243 | export function deleteMatch(db: Database, id: string): boolean { | |
| 244 | const row = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(id); | |
| 245 | if (row === null) return false; | |
| 246 | const changed = db.query("DELETE FROM match WHERE id = ?1").run(id).changes > 0; | |
| 247 | if (changed) recomputeStanding(db, row.identity); | |
| 248 | return changed; | |
| 249 | } | |
| 200 | 250 | |
| 201 | 251 | export const ratio = (kills: number, deaths: number): string => |
| 202 | 252 | (kills / Math.max(1, deaths)).toFixed(2); |