From e3301197531262b7f53955a860038f54cc8378b7 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 5 Jun 2026 23:38:43 -0400 Subject: [PATCH] feat: cap job_runs history at last 10 per job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailscale, Patchmon, and Semaphore sync jobs all wrote into a shared job_runs table with no retention. With default poll intervals of 15-60 minutes, history grew unbounded. - Add pruneJobRuns(jobId) and pruneAllJobRuns() helpers. - Prune after every completeJobRun() so new runs trim old ones. - Prune once on init() to clean up existing over-cap rows. - Prune in importJobs() so re-imported runs are also capped. - Defensive LIMIT 10 in getJobRuns() for the read path. No UI changes needed — _renderRunList already renders whatever the server returns. No schema migration — only row deletions. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/db.js | 25 +++++++++++++++++++++++-- tests/db.test.js | 13 +++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/db.js b/server/db.js index a7a29c9..2aafbf6 100644 --- a/server/db.js +++ b/server/db.js @@ -6,6 +6,8 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_PATH = join(__dirname, '../data/catalyst.db'); +const JOB_RUN_LIMIT = 10; + let db; function init(path) { @@ -17,7 +19,7 @@ function init(path) { db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA synchronous = NORMAL'); createSchema(); - if (path !== ':memory:') { seed(); seedJobs(); } + if (path !== ':memory:') { seed(); seedJobs(); pruneAllJobRuns(); } } function createSchema() { @@ -267,6 +269,7 @@ export function importJobs(jobRows, jobRunRows = []) { `); for (const r of jobRunRows) insertRun.run(r); } + pruneAllJobRuns(); db.exec('COMMIT'); } @@ -326,10 +329,28 @@ export function completeJobRun(runId, status, result) { db.prepare(` UPDATE job_runs SET ended_at=strftime('%Y-%m-%dT%H:%M:%f', 'now'), status=@status, result=@result WHERE id=@id `).run({ id: runId, status, result }); + const row = db.prepare('SELECT job_id FROM job_runs WHERE id = ?').get(runId); + if (row) pruneJobRuns(row.job_id); } export function getJobRuns(jobId) { - return db.prepare('SELECT * FROM job_runs WHERE job_id = ? ORDER BY id DESC').all(jobId); + return db.prepare(`SELECT * FROM job_runs WHERE job_id = ? ORDER BY id DESC LIMIT ${JOB_RUN_LIMIT}`).all(jobId); +} + +function pruneJobRuns(jobId) { + db.prepare(` + DELETE FROM job_runs + WHERE job_id = ? + AND id NOT IN ( + SELECT id FROM job_runs WHERE job_id = ? ORDER BY id DESC LIMIT ? + ) + `).run(jobId, jobId, JOB_RUN_LIMIT); +} + +function pruneAllJobRuns() { + for (const j of db.prepare('SELECT id FROM jobs').all()) { + pruneJobRuns(j.id); + } } // ── Test helpers ────────────────────────────────────────────────────────────── diff --git a/tests/db.test.js b/tests/db.test.js index 0cc65b7..f5c639a 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -450,4 +450,17 @@ describe('job_runs', () => { expect(runs[0].id).toBe(r2); expect(runs[1].id).toBe(r1); }); + + it('caps history at the last 10 runs per job', () => { + createJob(baseJob); + const id = getJobs()[0].id; + for (let i = 0; i < 15; i++) { + const runId = createJobRun(id); + completeJobRun(runId, 'success', `run ${i}`); + } + const runs = getJobRuns(id); + expect(runs).toHaveLength(10); + expect(runs[0].result).toBe('run 14'); + expect(runs[9].result).toBe('run 5'); + }); });