diff --git a/src/lib/server/reports/revisions.ts b/src/lib/server/reports/revisions.ts index 64cbcc7..60e14f2 100644 --- a/src/lib/server/reports/revisions.ts +++ b/src/lib/server/reports/revisions.ts @@ -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')"); + })(); } diff --git a/tests/unit/leaderboard-ingest.test.ts b/tests/unit/leaderboard-ingest.test.ts index a34292c..d88b8ab 100644 --- a/tests/unit/leaderboard-ingest.test.ts +++ b/tests/unit/leaderboard-ingest.test.ts @@ -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');