Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/loopover-miner/lib/portfolio-queue.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
6 changes: 6 additions & 0 deletions packages/loopover-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
},
Expand Down
13 changes: 10 additions & 3 deletions packages/loopover-miner/lib/purge-cli.js
Original file line number Diff line number Diff line change
@@ -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<string, unknown>` 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.
Expand All @@ -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";
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/loopover-miner/lib/run-state.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
6 changes: 6 additions & 0 deletions packages/loopover-miner/lib/run-state.js
Original file line number Diff line number Diff line change
@@ -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"]);

Expand Down Expand Up @@ -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();
},
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/store-maintenance.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/store-maintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_]*$/;

Expand Down
26 changes: 26 additions & 0 deletions test/unit/miner-portfolio-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
33 changes: 30 additions & 3 deletions test/unit/miner-purge-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};

Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -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);
});

Expand Down
28 changes: 28 additions & 0 deletions test/unit/miner-run-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});