Invalidate leaderboard reports only for visible data changes

This commit is contained in:
2026-10-06 14:04:25 -04:00
parent fbf0629486
commit 283a553cc2
2 changed files with 48 additions and 7 deletions
+23 -7
View File
@@ -3,12 +3,28 @@ import type Database from 'better-sqlite3';
export function installReportRevisions(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'],
leaderboard: ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players'] })) {
for (const table of tables) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='${topic}'; END;`);
}
for (const table of ['metric_samples', 'servers']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
if (handle.prepare("SELECT 1 FROM settings WHERE key='completed_report_revisions_v1'").get()) return;
handle.transaction(() => {
for (const table of ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players']) {
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const name = `report_${table}_${action}`;
const versions = action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const condition = table === 'leaderboard_matches'
? `WHEN ${versions.map(row => `${row}.status='completed'`).join(' OR ')}`
: table === 'leaderboard_match_players'
? `WHEN ${versions.map(row => `(SELECT status FROM leaderboard_matches WHERE id=${row}.match_id)='completed'`).join(' OR ')}`
: action === 'UPDATE' ? 'WHEN OLD.name IS NOT NEW.name' : '';
const event = table === 'leaderboard_players' && action === 'UPDATE' ? 'UPDATE OF name' : action;
handle.exec(`DROP TRIGGER IF EXISTS ${name};
CREATE TRIGGER ${name} AFTER ${event} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='leaderboard'; END;`);
}
}
handle.exec("INSERT INTO settings(key,value) VALUES('completed_report_revisions_v1','1')");
})();
}
+25
View File
@@ -34,6 +34,31 @@ async function matches() {
}>;
}
it('keeps report revisions stable during a match and invalidates completed data and renamed players', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const revision = () => (db().prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'").get() as { revision: number }).revision;
await report('game_start');
const started = revision();
for (let round = 1; round <= 30; round++) {
await report('round_change', round, [player(undefined, { kills: round * 10 })]);
await report('heartbeat', round);
}
expect(revision()).toBe(started);
await report('game_end', 30);
expect(revision()).toBeGreaterThan(started);
const completed = revision();
db().prepare('UPDATE leaderboard_players SET last_seen_at=?').run('2026-10-06T12:00:00.000Z');
expect(revision()).toBe(completed);
db().prepare("UPDATE leaderboard_players SET name='Renamed'").run();
expect(revision()).toBe(completed + 1);
db().prepare('UPDATE leaderboard_match_players SET kills=kills+1').run();
expect(revision()).toBe(completed + 2);
closeDb();
const reopened = revision();
db().prepare('DELETE FROM leaderboard_matches').run();
expect(revision()).toBeGreaterThan(reopened);
});
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');