From 6fbfed746de018a9c7ca1841a70aaf2801c70b7d Mon Sep 17 00:00:00 2001 From: luciferlive112116 <291889058+luciferlive112116@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:42:17 +0800 Subject: [PATCH] fix(miner): purge contribution-profile-cache and governor-state's repo-scoped tables (#7091) purge-cli.js's right-to-be-forgotten sweep covered only six of the miner's repo-scoped local stores. Three genuinely repo-scoped tables with the same `repoColumn` shape were missed, leaving per-repo data uncleaned: contribution-profile-cache's miner_contribution_profile table (keyed repo_full_name), and governor-state's governor_reputation_history and governor_own_submissions tables. Add the three purge specs to store-maintenance.js (the cache table name comes from its schema module's own CONTRIBUTION_PROFILE_STORE_TABLE constant so it can't drift), give each store a purgeByRepo method reusing the shared identifier-guarded purgeStoreByRepo, and wire both into REAL_PURGE_TARGETS. governor-state purges BOTH its tables against a single open handle; its reputation history is deleted by repo_full_name alone so a purge clears the repo across every api_base_url it was recorded against. governor_scalar_state (a whole-run scalar row with no repo dimension) is deliberately untouched. The dry-run path counts multi-table targets via a `specs` array. Closes #7091 --- .../lib/contribution-profile-cache.d.ts | 2 + .../lib/contribution-profile-cache.js | 15 ++ .../loopover-miner/lib/governor-state.d.ts | 2 + packages/loopover-miner/lib/governor-state.js | 22 +++ packages/loopover-miner/lib/purge-cli.js | 23 +++- .../loopover-miner/lib/store-maintenance.d.ts | 3 + .../loopover-miner/lib/store-maintenance.js | 13 ++ test/unit/miner-purge-cli.test.ts | 130 +++++++++++++++++- 8 files changed, 199 insertions(+), 11 deletions(-) diff --git a/packages/loopover-miner/lib/contribution-profile-cache.d.ts b/packages/loopover-miner/lib/contribution-profile-cache.d.ts index 4d4ce1d4b0..6a953d9831 100644 --- a/packages/loopover-miner/lib/contribution-profile-cache.d.ts +++ b/packages/loopover-miner/lib/contribution-profile-cache.d.ts @@ -12,6 +12,8 @@ export type ContributionProfileCache = { profile: ContributionProfile, nowMs?: number, ): { repoFullName: string; fetchedAt: string }; + /** Delete the cached profile for one repo (#7091); returns rows removed (0 or 1). */ + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/contribution-profile-cache.js b/packages/loopover-miner/lib/contribution-profile-cache.js index 5cb5d874d6..6f4810f5b9 100644 --- a/packages/loopover-miner/lib/contribution-profile-cache.js +++ b/packages/loopover-miner/lib/contribution-profile-cache.js @@ -13,6 +13,10 @@ import { resolveLocalStoreDbPath, } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { + CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, + purgeStoreByRepo, +} from "./store-maintenance.js"; const defaultDbFileName = "contribution-profile-cache.sqlite3"; let defaultContributionProfileCache = null; @@ -111,6 +115,17 @@ export function initContributionProfileCache( putStatement.run(repoFullName, JSON.stringify(profile), fetchedAt); return { repoFullName, fetchedAt }; }, + /** + * Delete the cached profile for one repo (#7091) — the right-to-be-forgotten path `loopover-miner purge` + * invokes. Returns the number of rows removed (0 or 1, since repo_full_name is the primary key). Reuses + * store-maintenance.js's identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. + * + * @param {string} repoFullName + * @returns {number} rows deleted + */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/governor-state.d.ts b/packages/loopover-miner/lib/governor-state.d.ts index fd62be4224..e8d0ef14f9 100644 --- a/packages/loopover-miner/lib/governor-state.d.ts +++ b/packages/loopover-miner/lib/governor-state.d.ts @@ -33,6 +33,8 @@ export type GovernorState = { saveReputationHistory(repoFullName: string, history: RepoOutcomeHistory, apiBaseUrl?: string): RepoOutcomeHistory; recordOwnSubmission(record: OwnSubmissionRecord): OwnSubmissionRecord; listRecentOwnSubmissions(filter?: ListRecentOwnSubmissionsFilter): OwnSubmissionRecord[]; + /** Delete every repo-scoped row for one repo across both governor tables (#7091); returns total rows removed. */ + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/governor-state.js b/packages/loopover-miner/lib/governor-state.js index 57c093a1bd..b35cd477ba 100644 --- a/packages/loopover-miner/lib/governor-state.js +++ b/packages/loopover-miner/lib/governor-state.js @@ -1,5 +1,10 @@ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { + GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC, + GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, + purgeStoreByRepo, +} from "./store-maintenance.js"; // Governor cross-attempt state persistence (#5134, Wave 3.5). Every governor-*.js wrapper // (governor-chokepoint.js) is a pure in/out transform: it computes and RETURNS @@ -304,6 +309,23 @@ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) { : listSubmissionsByRepoStatement.all(normalizeRepoFullName(filter.repoFullName), limit); return rows.map(rowToSubmission); }, + /** + * Delete every repo-scoped row for one repo across BOTH governor tables against this single open handle + * (#7091) — the right-to-be-forgotten path `loopover-miner purge` invokes. `governor_reputation_history` is + * purged on `repo_full_name` alone (its key is composite with `api_base_url`), so nothing survives on any + * forge host. `governor_scalar_state` is deliberately untouched — it has no repo dimension. Returns the + * total rows removed across both tables. + * + * @param {string} repoFullName + * @returns {number} rows deleted across both repo-scoped tables + */ + purgeByRepo(repoFullName) { + const normalized = normalizeRepoFullName(repoFullName); + return ( + purgeStoreByRepo(db, GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, normalized) + + purgeStoreByRepo(db, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC, normalized) + ); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/purge-cli.js b/packages/loopover-miner/lib/purge-cli.js index bfcd09cf3f..7441223b55 100644 --- a/packages/loopover-miner/lib/purge-cli.js +++ b/packages/loopover-miner/lib/purge-cli.js @@ -1,7 +1,8 @@ // `loopover-miner purge` (#5564, #6599): an explicit, operator-invoked right-to-be-forgotten path across the local -// ledgers. Deletes every row for one repo from the six stores that have a real `repoColumn` (claim-ledger, -// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state), via each store's own -// `purgeByRepo` method (which reuses `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). +// ledgers. Deletes every row for one repo from the stores that have a real `repoColumn` (claim-ledger, +// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, and +// governor-state's two repo-scoped tables — #7091), via each store's own `purgeByRepo` method (which reuses +// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). // `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its // payload is a free-form `Record` with no dedicated repo column, so a precise per-repo match // isn't possible there without risking false matches -- see store-maintenance.js's own purge-spec doc comment. @@ -17,6 +18,8 @@ import { initGovernorLedger, resolveGovernorLedgerDbPath } from "./governor-ledg import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; import { initRunStateStore, resolveRunStateDbPath } from "./run-state.js"; +import { initContributionProfileCache, resolveContributionProfileCacheDbPath } from "./contribution-profile-cache.js"; +import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, @@ -25,6 +28,9 @@ import { PREDICTION_LEDGER_PURGE_SPEC, PORTFOLIO_QUEUE_PURGE_SPEC, RUN_STATE_PURGE_SPEC, + CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, + GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, + GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC, countStoreByRepo, describeError, } from "./store-maintenance.js"; @@ -42,6 +48,10 @@ const REAL_PURGE_TARGETS = [ { name: "prediction-ledger", optionKey: "initPredictionLedger", opener: initPredictionLedger, resolveDbPath: resolvePredictionLedgerDbPath, spec: PREDICTION_LEDGER_PURGE_SPEC }, { name: "portfolio-queue", optionKey: "initPortfolioQueueStore", opener: initPortfolioQueueStore, resolveDbPath: resolvePortfolioQueueDbPath, spec: PORTFOLIO_QUEUE_PURGE_SPEC }, { name: "run-state", optionKey: "initRunStateStore", opener: initRunStateStore, resolveDbPath: resolveRunStateDbPath, spec: RUN_STATE_PURGE_SPEC }, + { name: "contribution-profile-cache", optionKey: "initContributionProfileCache", opener: initContributionProfileCache, resolveDbPath: resolveContributionProfileCacheDbPath, spec: CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC }, + // governor-state holds TWO repo-scoped tables in one DB file; its store.purgeByRepo deletes both against a + // single handle (never reopening the file), and its dry-run count sums both via `specs` (#7091). + { name: "governor-state", optionKey: "openGovernorState", opener: openGovernorState, resolveDbPath: resolveGovernorStateDbPath, specs: [GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC] }, ]; function parseRepoArg(value, usage) { @@ -113,8 +123,13 @@ export function runPurgeDryRun(parsed, options = {}) { const resolveDbPaths = options.resolveDbPaths ?? {}; const stores = REAL_PURGE_TARGETS.map((target) => { const dbPath = (resolveDbPaths[target.name] ?? target.resolveDbPath)(); + // A target scopes one table (`spec`) or -- for governor-state -- several in one file (`specs`); sum the + // per-table counts against the single read-only handle so the preview matches what a real purge removes. + const specs = target.specs ?? [target.spec]; try { - const wouldPurge = countExistingRows(dbPath, (db) => countStoreByRepo(db, target.spec, parsed.repoFullName)); + const wouldPurge = countExistingRows(dbPath, (db) => + specs.reduce((sum, spec) => sum + countStoreByRepo(db, spec, parsed.repoFullName), 0), + ); return { store: target.name, wouldPurge }; } catch (error) { return { store: target.name, wouldPurge: null, error: describeError(error) }; diff --git a/packages/loopover-miner/lib/store-maintenance.d.ts b/packages/loopover-miner/lib/store-maintenance.d.ts index 438c38d383..3b499bbc35 100644 --- a/packages/loopover-miner/lib/store-maintenance.d.ts +++ b/packages/loopover-miner/lib/store-maintenance.d.ts @@ -15,6 +15,9 @@ export const GOVERNOR_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const PREDICTION_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const PORTFOLIO_QUEUE_PURGE_SPEC: LedgerPurgeSpec; export const RUN_STATE_PURGE_SPEC: LedgerPurgeSpec; +export const CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC: LedgerPurgeSpec; +export const GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC: LedgerPurgeSpec; +export const GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC: LedgerPurgeSpec; export type StoreIntegrityResult = { name: string; ok: boolean; detail: string }; export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number }; diff --git a/packages/loopover-miner/lib/store-maintenance.js b/packages/loopover-miner/lib/store-maintenance.js index 70a4243212..ca0c7fdda6 100644 --- a/packages/loopover-miner/lib/store-maintenance.js +++ b/packages/loopover-miner/lib/store-maintenance.js @@ -13,6 +13,7 @@ // no internal clock read in the prune path so it stays deterministic and unit-testable. import { existsSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; +import { CONTRIBUTION_PROFILE_STORE_TABLE } from "./contribution-profile.js"; /** Env opt-ins for ledger retention (unset ⇒ retention disabled). */ export const LEDGER_RETENTION_DAYS_ENV = "LOOPOVER_MINER_LEDGER_RETENTION_DAYS"; @@ -35,6 +36,18 @@ export const PREDICTION_LEDGER_PURGE_SPEC = { table: "predictions", repoColumn: export const PORTFOLIO_QUEUE_PURGE_SPEC = { table: "miner_portfolio_queue", repoColumn: "repo_full_name" }; export const RUN_STATE_PURGE_SPEC = { table: "miner_run_state", repoColumn: "repo_full_name" }; +/** Three more repo-scoped stores the original six missed (#7091), same `repoColumn` shape and same internal- + * constant-only discipline. The contribution-profile-cache table name comes from its schema module's own + * `CONTRIBUTION_PROFILE_STORE_TABLE` constant so this spec can't drift from a second hardcoded literal. + * governor-state holds two genuinely repo-scoped tables (reputation history + own submissions); + * `governor_scalar_state` is intentionally excluded — it is a single whole-run scalar row with no repo + * dimension. `governor_reputation_history` is purged on `repo_full_name` alone (its key is composite with + * `api_base_url`), so a right-to-be-forgotten sweep clears the repo across every forge host it was recorded + * against, not just the default one. */ +export const CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC = { table: CONTRIBUTION_PROFILE_STORE_TABLE, repoColumn: "repo_full_name" }; +export const GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC = { table: "governor_reputation_history", repoColumn: "repo_full_name" }; +export const GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC = { table: "governor_own_submissions", repoColumn: "repo_full_name" }; + const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; /** A readable message for a caught value, whether or not it is an Error. */ diff --git a/test/unit/miner-purge-cli.test.ts b/test/unit/miner-purge-cli.test.ts index d6429b68bf..afb151fc2e 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -12,6 +12,12 @@ import { } from "../../packages/loopover-miner/lib/portfolio-queue.js"; import { initRunStateStore, closeDefaultRunStateStore } from "../../packages/loopover-miner/lib/run-state.js"; import { initAttemptLog, closeDefaultAttemptLog } from "../../packages/loopover-miner/lib/attempt-log.js"; +import { + initContributionProfileCache, + closeDefaultContributionProfileCache, +} from "../../packages/loopover-miner/lib/contribution-profile-cache.js"; +import { openGovernorState } from "../../packages/loopover-miner/lib/governor-state.js"; +import { emptyContributionProfile } from "../../packages/loopover-miner/lib/contribution-profile.js"; import { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, parsePurgeArgs, @@ -36,6 +42,7 @@ afterEach(() => { closeDefaultPortfolioQueueStore(); closeDefaultRunStateStore(); closeDefaultAttemptLog(); + closeDefaultContributionProfileCache(); vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -76,7 +83,7 @@ describe("parsePurgeArgs (#5564)", () => { }); describe("runPurge --dry-run (#5564, #6599)", () => { - it("counts matching rows across the six real stores without writing anything, and reports attempt-log as not-purgeable", async () => { + it("counts matching rows across the eight real stores without writing anything, and reports attempt-log as not-purgeable", async () => { const root = tempDir(); const claimDbPath = join(root, "claim-ledger.sqlite3"); const eventDbPath = join(root, "event-ledger.sqlite3"); @@ -84,6 +91,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { const predictionDbPath = join(root, "prediction-ledger.sqlite3"); const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); const runStateDbPath = join(root, "run-state.sqlite3"); + const cacheDbPath = join(root, "contribution-profile-cache.sqlite3"); + const governorStateDbPath = join(root, "governor-state.sqlite3"); const attemptLogDbPath = join(root, "attempt-log.sqlite3"); // never created — dry run must not touch it const claimLedger = openClaimLedger(claimDbPath); @@ -128,6 +137,22 @@ describe("runPurge --dry-run (#5564, #6599)", () => { runState.setRunState("acme/other", "idle"); runState.close(); + const cache = initContributionProfileCache(cacheDbPath); + cache.put(emptyContributionProfile("acme/widgets", "2026-07-17T00:00:00.000Z")); + cache.put(emptyContributionProfile("acme/other", "2026-07-17T00:00:00.000Z")); + cache.close(); + + // governor-state's two repo-scoped tables. reputation history for acme/widgets is recorded under TWO + // api_base_urls (both count for the repo, since the purge filters on repo_full_name alone) plus two own + // submissions; the whole-run scalar row (governor_scalar_state) is not repo-scoped and never counted. + const governorState = openGovernorState(governorStateDbPath); + governorState.saveReputationHistory("acme/widgets", { decided: 5, unfavorable: 2 }, "https://api.github.com"); + governorState.saveReputationHistory("acme/widgets", { decided: 3, unfavorable: 1 }, "https://gitlab.example/api"); + governorState.saveReputationHistory("acme/other", { decided: 1, unfavorable: 0 }); + governorState.recordOwnSubmission({ repoFullName: "acme/widgets", fingerprint: "fp-1" }); + governorState.recordOwnSubmission({ repoFullName: "acme/widgets", fingerprint: "fp-2" }); + governorState.close(); + const resolveDbPaths = { "claim-ledger": () => claimDbPath, "event-ledger": () => eventDbPath, @@ -135,6 +160,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "prediction-ledger": () => predictionDbPath, "portfolio-queue": () => portfolioDbPath, "run-state": () => runStateDbPath, + "contribution-profile-cache": () => cacheDbPath, + "governor-state": () => governorStateDbPath, "attempt-log": () => attemptLogDbPath, }; @@ -151,6 +178,9 @@ describe("runPurge --dry-run (#5564, #6599)", () => { { store: "prediction-ledger", wouldPurge: 0 }, { store: "portfolio-queue", wouldPurge: 2 }, { store: "run-state", wouldPurge: 1 }, + { store: "contribution-profile-cache", wouldPurge: 1 }, + // governor-state sums BOTH tables: 2 reputation rows (two api_base_urls) + 2 own submissions = 4. + { store: "governor-state", wouldPurge: 4 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -180,12 +210,14 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), "run-state": () => join(root, "run-state.sqlite3"), + "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), + "governor-state": () => join(root, "governor-state.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(6); + expect(result.stores).toHaveLength(8); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); expect(result.attemptLogTotalRows).toBe(0); for (const resolve of Object.values(resolveDbPaths)) { @@ -220,6 +252,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), "run-state": () => join(root, "run-state.sqlite3"), + "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), + "governor-state": () => join(root, "governor-state.sqlite3"), "attempt-log": () => attemptLogDbPath, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -245,6 +279,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), "run-state": () => join(root, "run-state.sqlite3"), + "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), + "governor-state": () => join(root, "governor-state.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -283,6 +319,8 @@ describe("runPurge --dry-run (#5564, #6599)", () => { LOOPOVER_MINER_PREDICTION_LEDGER_DB: process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB, LOOPOVER_MINER_PORTFOLIO_QUEUE_DB: process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB, LOOPOVER_MINER_RUN_STATE_DB: process.env.LOOPOVER_MINER_RUN_STATE_DB, + LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB: process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB, + LOOPOVER_MINER_GOVERNOR_STATE_DB: process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB, LOOPOVER_MINER_ATTEMPT_LOG_DB: process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB, }; process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB = join(root, "claim-ledger.sqlite3"); @@ -291,12 +329,14 @@ describe("runPurge --dry-run (#5564, #6599)", () => { process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = join(root, "prediction-ledger.sqlite3"); process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB = join(root, "portfolio-queue.sqlite3"); process.env.LOOPOVER_MINER_RUN_STATE_DB = join(root, "run-state.sqlite3"); + process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB = join(root, "contribution-profile-cache.sqlite3"); + process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB = join(root, "governor-state.sqlite3"); process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB = join(root, "attempt-log.sqlite3"); try { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"])).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); - expect(result.stores).toHaveLength(6); + expect(result.stores).toHaveLength(8); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); // Nothing was created — dry run against nonexistent default-path stores makes zero writes. expect(existsSync(process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB)).toBe(false); @@ -323,6 +363,8 @@ describe("runPurge (real, #5564, #6599)", () => { const prediction = fakeStore(3); const portfolio = fakeStore(4); const runState = fakeStore(1); + const cache = fakeStore(1); + const governorState = fakeStore(4); // its purgeByRepo already sums both repo-scoped tables const options = { openClaimLedger: () => claim, initEventLedger: () => event, @@ -330,6 +372,8 @@ describe("runPurge (real, #5564, #6599)", () => { initPredictionLedger: () => prediction, initPortfolioQueueStore: () => portfolio, initRunStateStore: () => runState, + initContributionProfileCache: () => cache, + openGovernorState: () => governorState, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -338,7 +382,7 @@ describe("runPurge (real, #5564, #6599)", () => { expect(summary).toMatchObject({ outcome: "purged", repoFullName: "acme/widgets", - totalPurged: 11, + totalPurged: 16, stores: [ { store: "claim-ledger", purged: 2 }, { store: "event-ledger", purged: 1 }, @@ -346,22 +390,24 @@ describe("runPurge (real, #5564, #6599)", () => { { store: "prediction-ledger", purged: 3 }, { store: "portfolio-queue", purged: 4 }, { store: "run-state", purged: 1 }, + { store: "contribution-profile-cache", purged: 1 }, + { store: "governor-state", purged: 4 }, { store: "attempt-log", purged: null, note: ATTEMPT_LOG_NOT_PURGEABLE_NOTE }, ], }); expect(typeof summary.purgedAt).toBe("string"); - for (const store of [claim, event, governor, prediction, portfolio, runState]) { + for (const store of [claim, event, governor, prediction, portfolio, runState, cache, governorState]) { expect(store.purgeByRepo).toHaveBeenCalledWith("acme/widgets"); } // Injected stores are caller-owned: runPurge must not close them. - for (const store of [claim, event, governor, prediction, portfolio, runState]) { + for (const store of [claim, event, governor, prediction, portfolio, runState, cache, governorState]) { expect(store.close).not.toHaveBeenCalled(); } log.mockClear(); expect(runPurge(["--repo", "acme/widgets"], options as never)).toBe(0); const text = String(log.mock.calls[0]?.[0]); - expect(text).toContain("Purged 11 row(s) for acme/widgets"); + expect(text).toContain("Purged 16 row(s) for acme/widgets"); expect(text).toContain("claim-ledger=2"); expect(text).toContain("portfolio-queue=4"); expect(text).toContain("run-state=1"); @@ -384,6 +430,8 @@ describe("runPurge (real, #5564, #6599)", () => { initPredictionLedger: () => prediction, initPortfolioQueueStore: () => portfolio, initRunStateStore: () => runState, + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -417,6 +465,8 @@ describe("runPurge (real, #5564, #6599)", () => { initPredictionLedger: () => fakeStore(0), initPortfolioQueueStore: () => fakeStore(0), initRunStateStore: () => fakeStore(0), + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -434,6 +484,8 @@ describe("runPurge (real, #5564, #6599)", () => { initPredictionLedger: () => fakeStore(0), initPortfolioQueueStore: () => fakeStore(0), initRunStateStore: () => fakeStore(0), + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -453,6 +505,8 @@ describe("runPurge (real, #5564, #6599)", () => { LOOPOVER_MINER_PREDICTION_LEDGER_DB: process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB, LOOPOVER_MINER_PORTFOLIO_QUEUE_DB: process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB, LOOPOVER_MINER_RUN_STATE_DB: process.env.LOOPOVER_MINER_RUN_STATE_DB, + LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB: process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB, + LOOPOVER_MINER_GOVERNOR_STATE_DB: process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB, }; const claimDbPath = join(root, "claim-ledger.sqlite3"); const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); @@ -463,6 +517,8 @@ describe("runPurge (real, #5564, #6599)", () => { process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = join(root, "prediction-ledger.sqlite3"); process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB = portfolioDbPath; process.env.LOOPOVER_MINER_RUN_STATE_DB = runStateDbPath; + process.env.LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB = join(root, "contribution-profile-cache.sqlite3"); + process.env.LOOPOVER_MINER_GOVERNOR_STATE_DB = join(root, "governor-state.sqlite3"); try { // Seed real rows via the default store paths before purging through them. const seededClaim = openClaimLedger(claimDbPath); @@ -527,6 +583,8 @@ describe("runPurge (real, #5564, #6599)", () => { "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), "portfolio-queue": () => portfolioDbPath, "run-state": () => runStateDbPath, + "contribution-profile-cache": () => join(root, "contribution-profile-cache.sqlite3"), + "governor-state": () => join(root, "governor-state.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; @@ -548,6 +606,8 @@ describe("runPurge (real, #5564, #6599)", () => { initPredictionLedger: () => fakeStore(0), initPortfolioQueueStore: () => portfolioStore, initRunStateStore: () => runStateStore, + initContributionProfileCache: () => fakeStore(0), + openGovernorState: () => fakeStore(0), } as never), ).toBe(0); const purged = JSON.parse(String(log.mock.calls[0]?.[0])); @@ -556,4 +616,60 @@ describe("runPurge (real, #5564, #6599)", () => { expect(portfolioStore.listQueue()).toEqual([]); expect(runStateStore.listRunStates()).toEqual([]); }); + + it("REGRESSION (#7091): really deletes contribution-profile-cache + BOTH governor tables across api_base_urls, leaving other repos and the whole-run scalar row intact", () => { + const root = tempDir(); + const cacheDbPath = join(root, "contribution-profile-cache.sqlite3"); + const governorStateDbPath = join(root, "governor-state.sqlite3"); + + const seededCache = initContributionProfileCache(cacheDbPath); + seededCache.put(emptyContributionProfile("acme/widgets", "2026-07-17T00:00:00.000Z")); + seededCache.put(emptyContributionProfile("acme/other", "2026-07-17T00:00:00.000Z")); + seededCache.close(); + + const seededGovernor = openGovernorState(governorStateDbPath); + // acme/widgets recorded under TWO forge hosts -- both must be swept, since the purge filters on + // repo_full_name alone (the api_base_url half of the composite key is ignored). + seededGovernor.saveReputationHistory("acme/widgets", { decided: 5, unfavorable: 2 }, "https://api.github.com"); + seededGovernor.saveReputationHistory("acme/widgets", { decided: 3, unfavorable: 1 }, "https://gitlab.example/api"); + seededGovernor.saveReputationHistory("acme/other", { decided: 1, unfavorable: 0 }); + seededGovernor.recordOwnSubmission({ repoFullName: "acme/widgets", fingerprint: "fp-1" }); + seededGovernor.recordOwnSubmission({ repoFullName: "acme/other", fingerprint: "fp-2" }); + seededGovernor.savePauseState({ paused: true, reason: "maintenance" }); // scalar row -- must survive the purge + seededGovernor.close(); + + // Inject the real openers against the seeded files (caller-owned, so we close them ourselves afterward). + const cacheStore = initContributionProfileCache(cacheDbPath); + const governorStore = openGovernorState(governorStateDbPath); + closeables.push(cacheStore, governorStore); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runPurge(["--repo", "acme/widgets", "--json"], { + openClaimLedger: () => fakeStore(0), + initEventLedger: () => fakeStore(0), + initGovernorLedger: () => fakeStore(0), + initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => fakeStore(0), + initRunStateStore: () => fakeStore(0), + initContributionProfileCache: () => cacheStore, + openGovernorState: () => governorStore, + } as never), + ).toBe(0); + const summary = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(summary.stores).toContainEqual({ store: "contribution-profile-cache", purged: 1 }); + // governor-state sums both repo-scoped tables for acme/widgets: 2 reputation rows + 1 own submission = 3. + expect(summary.stores).toContainEqual({ store: "governor-state", purged: 3 }); + + // acme/widgets is gone from every purged table across both forge hosts... + expect(cacheStore.get("acme/widgets")).toBeNull(); + expect(governorStore.loadReputationHistory("acme/widgets", "https://api.github.com")).toEqual({ decided: 0, unfavorable: 0 }); + expect(governorStore.loadReputationHistory("acme/widgets", "https://gitlab.example/api")).toEqual({ decided: 0, unfavorable: 0 }); + expect(governorStore.listRecentOwnSubmissions({ repoFullName: "acme/widgets" })).toEqual([]); + // ...while another repo's rows and the non-repo-scoped scalar pause row are untouched. + expect(cacheStore.get("acme/other")).not.toBeNull(); + expect(governorStore.loadReputationHistory("acme/other")).toEqual({ decided: 1, unfavorable: 0 }); + expect(governorStore.listRecentOwnSubmissions({ repoFullName: "acme/other" })).toHaveLength(1); + expect(governorStore.loadPauseState()).toMatchObject({ paused: true, reason: "maintenance" }); + }); });