diff --git a/packages/loopover-miner/lib/portfolio-queue.d.ts b/packages/loopover-miner/lib/portfolio-queue.d.ts index 9a316a0d10..7c2053e278 100644 --- a/packages/loopover-miner/lib/portfolio-queue.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue.d.ts @@ -50,6 +50,7 @@ export type PortfolioQueueStore = { ) => Array<{ repoFullName: string; identifier: string; apiBaseUrl?: string }>, ): QueueEntry[]; getAttemptHistory(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueAttemptHistory; + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/portfolio-queue.js b/packages/loopover-miner/lib/portfolio-queue.js index f93c43f9b3..5cfd3776fd 100644 --- a/packages/loopover-miner/lib/portfolio-queue.js +++ b/packages/loopover-miner/lib/portfolio-queue.js @@ -1,6 +1,7 @@ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { PORTFOLIO_QUEUE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; // The miner's local portfolio/queue store (#2292): a 100% client-side, prioritized backlog of candidate work // items across every repo the miner has been pointed at ("what should I look at next, across everything I'm @@ -385,6 +386,11 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) reachedDone: row.status === "done", }; }, + /** Right-to-be-forgotten (#5564/#6599): delete every row for one repo, mirroring the ledgers' purgeByRepo. + * Throws on a missing/malformed repoFullName rather than silently no-opping. */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, PORTFOLIO_QUEUE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/purge-cli.js b/packages/loopover-miner/lib/purge-cli.js index 91b2bc81e1..84cfd6826d 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): an explicit, operator-invoked right-to-be-forgotten path across the local -// ledgers. Deletes every row for one repo from the four stores that have a real `repoColumn` (claim-ledger, -// event-ledger, governor-ledger, prediction-ledger), via each store's own `purgeByRepo` method (which reuses -// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). `attempt-log.js` is deliberately +// 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 — the last two added in #6599), +// 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. @@ -15,12 +16,16 @@ import { openClaimLedger, resolveClaimLedgerDbPath } from "./claim-ledger.js"; import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; import { initGovernorLedger, resolveGovernorLedgerDbPath } from "./governor-ledger.js"; import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; +import { initRunStateStore, resolveRunStateDbPath } from "./run-state.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, EVENT_LEDGER_PURGE_SPEC, GOVERNOR_LEDGER_PURGE_SPEC, PREDICTION_LEDGER_PURGE_SPEC, + PORTFOLIO_QUEUE_PURGE_SPEC, + RUN_STATE_PURGE_SPEC, countStoreByRepo, describeError, } from "./store-maintenance.js"; @@ -36,6 +41,8 @@ const REAL_PURGE_TARGETS = [ { name: "event-ledger", optionKey: "initEventLedger", opener: initEventLedger, resolveDbPath: resolveEventLedgerDbPath, spec: EVENT_LEDGER_PURGE_SPEC }, { name: "governor-ledger", optionKey: "initGovernorLedger", opener: initGovernorLedger, resolveDbPath: resolveGovernorLedgerDbPath, spec: GOVERNOR_LEDGER_PURGE_SPEC }, { 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 }, ]; function parseRepoArg(value, usage) { diff --git a/packages/loopover-miner/lib/run-state.d.ts b/packages/loopover-miner/lib/run-state.d.ts index 2446e12142..fb1bed014d 100644 --- a/packages/loopover-miner/lib/run-state.d.ts +++ b/packages/loopover-miner/lib/run-state.d.ts @@ -19,6 +19,7 @@ export type RunStateStore = { getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null; setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite; listRunStates(): RunStateRow[]; + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/run-state.js b/packages/loopover-miner/lib/run-state.js index de9bfaa843..a69808a21f 100644 --- a/packages/loopover-miner/lib/run-state.js +++ b/packages/loopover-miner/lib/run-state.js @@ -1,6 +1,7 @@ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { RUN_STATE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; export const RUN_STATES = Object.freeze(["idle", "discovering", "planning", "preparing"]); @@ -133,6 +134,11 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { updatedAt: row.updated_at, })); }, + /** Right-to-be-forgotten (#5564/#6599): delete every row for one repo, mirroring the ledgers' purgeByRepo. + * Throws on a missing/malformed repoFullName rather than silently no-opping. */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, RUN_STATE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/store-maintenance.d.ts b/packages/loopover-miner/lib/store-maintenance.d.ts index f54483b6bb..438c38d383 100644 --- a/packages/loopover-miner/lib/store-maintenance.d.ts +++ b/packages/loopover-miner/lib/store-maintenance.d.ts @@ -13,6 +13,8 @@ export const CLAIM_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const EVENT_LEDGER_PURGE_SPEC: LedgerPurgeSpec; 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 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 073d2a1bfe..01be32cb26 100644 --- a/packages/loopover-miner/lib/store-maintenance.js +++ b/packages/loopover-miner/lib/store-maintenance.js @@ -32,6 +32,8 @@ export const CLAIM_LEDGER_PURGE_SPEC = { table: "miner_claims", repoColumn: "rep export const EVENT_LEDGER_PURGE_SPEC = { table: "miner_event_ledger", repoColumn: "repo_full_name" }; export const GOVERNOR_LEDGER_PURGE_SPEC = { table: "governor_events", repoColumn: "repo_full_name" }; export const PREDICTION_LEDGER_PURGE_SPEC = { table: "predictions", repoColumn: "repo_full_name" }; +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" }; const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/test/unit/miner-portfolio-queue.test.ts b/test/unit/miner-portfolio-queue.test.ts index 84f4b70d3d..3b85db709b 100644 --- a/test/unit/miner-portfolio-queue.test.ts +++ b/test/unit/miner-portfolio-queue.test.ts @@ -644,4 +644,30 @@ describe("loopover-miner portfolio/queue store (#2292)", () => { }).not.toThrow(); }); }); + + describe("purgeByRepo (#6599)", () => { + it("deletes every row for one repo and leaves other repos untouched", () => { + const store = tempStore(); + store.enqueue({ repoFullName: "owner/repo-a", identifier: "1" }); + store.enqueue({ repoFullName: "owner/repo-a", identifier: "2" }); + store.enqueue({ repoFullName: "owner/repo-b", identifier: "3" }); + + expect(store.purgeByRepo("owner/repo-a")).toBe(2); + expect(store.listQueue("owner/repo-a")).toEqual([]); + expect(store.listQueue()).toHaveLength(1); + }); + + it("returns 0 when nothing matches the repo", () => { + const store = tempStore(); + store.enqueue({ repoFullName: "owner/repo-b", identifier: "1" }); + expect(store.purgeByRepo("owner/repo-a")).toBe(0); + expect(store.listQueue()).toHaveLength(1); + }); + + it("rejects a missing/malformed repoFullName rather than silently no-opping", () => { + const store = tempStore(); + expect(() => store.purgeByRepo(undefined as never)).toThrow("invalid_repo_full_name"); + expect(() => store.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name"); + }); + }); }); diff --git a/test/unit/miner-purge-cli.test.ts b/test/unit/miner-purge-cli.test.ts index 89f6d79385..71fb916d3f 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -7,6 +7,8 @@ import { initEventLedger, closeDefaultEventLedger } from "../../packages/loopove import { initGovernorLedger, closeDefaultGovernorLedger } from "../../packages/loopover-miner/lib/governor-ledger.js"; import { initPredictionLedger, closeDefaultPredictionLedger } from "../../packages/loopover-miner/lib/prediction-ledger.js"; import { initAttemptLog, closeDefaultAttemptLog } from "../../packages/loopover-miner/lib/attempt-log.js"; +import { initPortfolioQueueStore, closeDefaultPortfolioQueueStore } from "../../packages/loopover-miner/lib/portfolio-queue.js"; +import { initRunStateStore, closeDefaultRunStateStore } from "../../packages/loopover-miner/lib/run-state.js"; import { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, parsePurgeArgs, @@ -75,6 +77,8 @@ describe("runPurge --dry-run (#5564)", () => { const eventDbPath = join(root, "event-ledger.sqlite3"); const governorDbPath = join(root, "governor-ledger.sqlite3"); const predictionDbPath = join(root, "prediction-ledger.sqlite3"); + const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); + const runStateDbPath = join(root, "run-state.sqlite3"); const attemptLogDbPath = join(root, "attempt-log.sqlite3"); // never created — dry run must not touch it const claimLedger = openClaimLedger(claimDbPath); @@ -108,11 +112,24 @@ describe("runPurge --dry-run (#5564)", () => { }); predictionLedger.close(); + const portfolioQueue = initPortfolioQueueStore(portfolioDbPath); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "1" }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "2" }); + portfolioQueue.enqueue({ repoFullName: "acme/other", identifier: "3" }); + portfolioQueue.close(); + + const runState = initRunStateStore(runStateDbPath); + runState.setRunState("acme/widgets", "discovering"); + runState.setRunState("acme/other", "planning"); + runState.close(); + const resolveDbPaths = { "claim-ledger": () => claimDbPath, "event-ledger": () => eventDbPath, "governor-ledger": () => governorDbPath, "prediction-ledger": () => predictionDbPath, + "portfolio-queue": () => portfolioDbPath, + "run-state": () => runStateDbPath, "attempt-log": () => attemptLogDbPath, }; @@ -127,6 +144,8 @@ describe("runPurge --dry-run (#5564)", () => { { store: "event-ledger", wouldPurge: 1 }, { store: "governor-ledger", wouldPurge: 1 }, { store: "prediction-ledger", wouldPurge: 0 }, + { store: "portfolio-queue", wouldPurge: 2 }, + { store: "run-state", wouldPurge: 1 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -283,11 +302,15 @@ describe("runPurge (real, #5564)", () => { const event = fakeStore(1); const governor = fakeStore(0); const prediction = fakeStore(3); + const portfolio = fakeStore(4); + const runState = fakeStore(1); const options = { openClaimLedger: () => claim, initEventLedger: () => event, initGovernorLedger: () => governor, initPredictionLedger: () => prediction, + initPortfolioQueueStore: () => portfolio, + initRunStateStore: () => runState, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -296,17 +319,19 @@ describe("runPurge (real, #5564)", () => { expect(summary).toMatchObject({ outcome: "purged", repoFullName: "acme/widgets", - totalPurged: 6, + totalPurged: 11, stores: [ { store: "claim-ledger", purged: 2 }, { store: "event-ledger", purged: 1 }, { store: "governor-ledger", purged: 0 }, { store: "prediction-ledger", purged: 3 }, + { store: "portfolio-queue", purged: 4 }, + { store: "run-state", purged: 1 }, { 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]) { + for (const store of [claim, event, governor, prediction, portfolio, runState]) { expect(store.purgeByRepo).toHaveBeenCalledWith("acme/widgets"); } // Injected stores are caller-owned: runPurge must not close them. @@ -317,8 +342,10 @@ describe("runPurge (real, #5564)", () => { log.mockClear(); expect(runPurge(["--repo", "acme/widgets"], options as never)).toBe(0); const text = String(log.mock.calls[0]?.[0]); - expect(text).toContain("Purged 6 row(s) for acme/widgets"); + expect(text).toContain("Purged 11 row(s) for acme/widgets"); expect(text).toContain("claim-ledger=2"); + expect(text).toContain("portfolio-queue=4"); + expect(text).toContain("run-state=1"); expect(text).toContain(ATTEMPT_LOG_NOT_PURGEABLE_NOTE); }); diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index 6d84e31f42..3e9be3a139 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -359,4 +359,32 @@ describe("loopover-miner run-state store (#2289)", () => { }).not.toThrow(); }); }); + + describe("purgeByRepo (#6599)", () => { + it("deletes every row for one repo and leaves other repos untouched", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + store.setRunState("owner/repo-a", "discovering"); + store.setRunState("owner/repo-b", "planning"); + + expect(store.purgeByRepo("owner/repo-a")).toBe(1); + expect(store.getRunState("owner/repo-a")).toBeNull(); + expect(store.listRunStates()).toHaveLength(1); + store.close(); + }); + + it("returns 0 when nothing matches the repo", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + store.setRunState("owner/repo-b", "discovering"); + expect(store.purgeByRepo("owner/repo-a")).toBe(0); + expect(store.listRunStates()).toHaveLength(1); + store.close(); + }); + + it("rejects a missing/malformed repoFullName rather than silently no-opping", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + expect(() => store.purgeByRepo(undefined as never)).toThrow("invalid_repo_full_name"); + expect(() => store.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name"); + store.close(); + }); + }); });