Skip to content

Commit f2af2d0

Browse files
committed
fix(miner): purge ranked-candidates, replay-snapshot, and deny-hook-synthesis stores by repo (#8009)
1 parent bf8bb14 commit f2af2d0

10 files changed

Lines changed: 334 additions & 10 deletions

packages/loopover-miner/lib/deny-hook-synthesis.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import type { DenyRuleProposal, SynthesisConfig } from "@loopover/engine";
2525
import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
2626
import type { DenyRule } from "./deny-hooks.js";
27+
import { DENY_HOOK_SYNTHESIS_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";
2728

2829
// Re-export the pure synthesis helpers from the engine so this module's public API is unchanged after #5667
2930
// moved derivation/audit into @loopover/engine. Only the SQLite store below (and its forge/db-path helpers) is
@@ -59,6 +60,8 @@ export type DenyHookSynthesisStore = {
5960
repoFullName: string,
6061
options?: { includeDefaults?: boolean; apiBaseUrl?: string },
6162
): DenyRule[];
63+
/** Delete every proposal row for one repo across ALL forge hosts (#8009); returns the number of rows removed. */
64+
purgeByRepo(repoFullName: string): number;
6265
close(): void;
6366
};
6467

@@ -248,6 +251,13 @@ export function initDenyHookSynthesisStore(dbPath: string = resolveDenyHookSynth
248251
approvedProposals: proposals,
249252
} as Parameters<typeof resolveEffectiveDenyRules>[0]);
250253
},
254+
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
255+
* `loopover-miner purge` invokes. Filters on `repo_full_name` alone (the spec's own doc covers why), so —
256+
* unlike every other method here, which scopes to one forge — the sweep clears the repo's proposals under
257+
* every `api_base_url` they were recorded against, mirroring governor-state's purgeByRepo. */
258+
purgeByRepo(repoFullName) {
259+
return purgeStoreByRepo(db, DENY_HOOK_SYNTHESIS_PURGE_SPEC, normalizeRepoFullName(repoFullName));
260+
},
251261
close() {
252262
db.close();
253263
},

packages/loopover-miner/lib/purge-cli.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// `loopover-miner purge` (#5564, #6599): an explicit, operator-invoked right-to-be-forgotten path across the local
22
// ledgers. Deletes every row for one repo from the stores that have a real `repoColumn` (claim-ledger,
3-
// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, and
4-
// governor-state's two repo-scoped tables — #7091), via each store's own `purgeByRepo` method (which reuses
3+
// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache,
4+
// governor-state's two repo-scoped tables — #7091 — plus policy-verdict-cache — #6987 — and ranked-candidates,
5+
// replay-snapshot, and deny-hook-synthesis — #8009), via each store's own `purgeByRepo` method (which reuses
56
// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`).
67
// `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its
78
// payload is a free-form `Record<string, unknown>` with no dedicated repo column, so a precise per-repo match
@@ -30,6 +31,12 @@ import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state.
3031
import type { GovernorState } from "./governor-state.js";
3132
import { initPolicyVerdictCacheStore, resolvePolicyVerdictCacheDbPath } from "./policy-verdict-cache.js";
3233
import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
34+
import { initRankedCandidatesStore, resolveRankedCandidatesDbPath } from "./ranked-candidates.js";
35+
import type { RankedCandidatesStore } from "./ranked-candidates.js";
36+
import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-snapshot.js";
37+
import type { ReplaySnapshotStore } from "./replay-snapshot.js";
38+
import { initDenyHookSynthesisStore, resolveDenyHookSynthesisDbPath } from "./deny-hook-synthesis.js";
39+
import type { DenyHookSynthesisStore } from "./deny-hook-synthesis.js";
3340
import { resolveAttemptLogDbPath } from "./attempt-log.js";
3441
import {
3542
CLAIM_LEDGER_PURGE_SPEC,
@@ -42,6 +49,9 @@ import {
4249
GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC,
4350
GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC,
4451
POLICY_VERDICT_CACHE_PURGE_SPEC,
52+
RANKED_CANDIDATES_PURGE_SPEC,
53+
REPLAY_SNAPSHOT_PURGE_SPEC,
54+
DENY_HOOK_SYNTHESIS_PURGE_SPEC,
4555
countStoreByRepo,
4656
describeError,
4757
} from "./store-maintenance.js";
@@ -66,7 +76,10 @@ type PurgeOpenerKey =
6676
| "initRunStateStore"
6777
| "initContributionProfileCache"
6878
| "openGovernorState"
69-
| "initPolicyVerdictCacheStore";
79+
| "initPolicyVerdictCacheStore"
80+
| "initRankedCandidatesStore"
81+
| "openReplaySnapshotStore"
82+
| "initDenyHookSynthesisStore";
7083

7184
export type PurgeCliOptions = {
7285
openClaimLedger?: () => ClaimLedger;
@@ -78,6 +91,9 @@ export type PurgeCliOptions = {
7891
initContributionProfileCache?: () => ContributionProfileCache;
7992
openGovernorState?: () => GovernorState;
8093
initPolicyVerdictCacheStore?: () => PolicyVerdictCacheStore;
94+
initRankedCandidatesStore?: () => RankedCandidatesStore;
95+
openReplaySnapshotStore?: () => ReplaySnapshotStore;
96+
initDenyHookSynthesisStore?: () => DenyHookSynthesisStore;
8197
resolveDbPaths?: Record<string, () => string>;
8298
};
8399

@@ -102,6 +118,12 @@ const REAL_PURGE_TARGETS: PurgeTarget[] = [
102118
// single handle (never reopening the file), and its dry-run count sums both via `specs` (#7091).
103119
{ name: "governor-state", optionKey: "openGovernorState", opener: openGovernorState, resolveDbPath: resolveGovernorStateDbPath, specs: [GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC] },
104120
{ name: "policy-verdict-cache", optionKey: "initPolicyVerdictCacheStore", opener: initPolicyVerdictCacheStore, resolveDbPath: resolvePolicyVerdictCacheDbPath, spec: POLICY_VERDICT_CACHE_PURGE_SPEC },
121+
// Three more repo-scoped stores the earlier sweeps missed (#8009). deny-hook-synthesis's dry-run count works
122+
// on both pre- and post-forge-scope files: its live table is `deny_rule_proposals` either way, and the purge
123+
// filters on `repo_full_name` alone (all forge hosts), per its spec's own doc in store-maintenance.js.
124+
{ name: "ranked-candidates", optionKey: "initRankedCandidatesStore", opener: initRankedCandidatesStore, resolveDbPath: resolveRankedCandidatesDbPath, spec: RANKED_CANDIDATES_PURGE_SPEC },
125+
{ name: "replay-snapshot", optionKey: "openReplaySnapshotStore", opener: openReplaySnapshotStore, resolveDbPath: resolveReplaySnapshotDbPath, spec: REPLAY_SNAPSHOT_PURGE_SPEC },
126+
{ name: "deny-hook-synthesis", optionKey: "initDenyHookSynthesisStore", opener: initDenyHookSynthesisStore, resolveDbPath: resolveDenyHookSynthesisDbPath, spec: DENY_HOOK_SYNTHESIS_PURGE_SPEC },
105127
];
106128

107129
export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string };

packages/loopover-miner/lib/ranked-candidates.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SQLOutputValue } from "node:sqlite";
22
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
33
import { applySchemaMigrations } from "./schema-version.js";
4+
import { RANKED_CANDIDATES_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";
45

56
// Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already
67
// computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via
@@ -54,6 +55,8 @@ export type RankedCandidatesStore = {
5455
dbPath: string;
5556
saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult;
5657
listRankedCandidates(): RankedCandidateRow[];
58+
/** Delete every snapshot row for one repo (#8009); returns the number of rows removed. */
59+
purgeByRepo(repoFullName: string): number;
5760
close(): void;
5861
};
5962

@@ -101,17 +104,25 @@ function normalizeFiniteRankDimension(value: unknown, fallback: number): number
101104
return Number.isFinite(value) ? (value as number) : fallback;
102105
}
103106

107+
/** Guard an owner/repo value to the canonical `owner/repo` shape. Shared by the candidate write path and
108+
* purgeByRepo (#8009), each throwing its own error name — a rejected candidate and a rejected purge target are
109+
* different operator mistakes. */
110+
function normalizeRepoFullName(value: unknown, error: string): string {
111+
const repoFullName = typeof value === "string" ? value.trim() : "";
112+
const [owner, repo, extra] = repoFullName.split("/");
113+
if (!owner || !repo || extra !== undefined) throw new Error(error);
114+
return `${owner}/${repo}`;
115+
}
116+
104117
function normalizeCandidate(candidate: RankedCandidateInput): NormalizedRankedCandidate {
105118
if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate");
106-
const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
107-
const [owner, repo, extra] = repoFullName.split("/");
108-
if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate");
119+
const repoFullName = normalizeRepoFullName(candidate.repoFullName, "invalid_ranked_candidate");
109120
const issueNumber = candidate.issueNumber;
110121
if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error("invalid_ranked_candidate");
111122
const rankScore = Number(candidate.rankScore);
112123
if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate");
113124
return {
114-
repoFullName: `${owner}/${repo}`,
125+
repoFullName,
115126
issueNumber,
116127
title: typeof candidate.title === "string" ? candidate.title : "",
117128
htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null,
@@ -225,6 +236,12 @@ export function initRankedCandidatesStore(dbPath: string = resolveRankedCandidat
225236
listRankedCandidates() {
226237
return listStatement.all().map((row) => rowToCandidate(asRankedCandidateDbRow(row)));
227238
},
239+
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
240+
* `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo,
241+
* exactly like the other repo-scoped stores. */
242+
purgeByRepo(repoFullName) {
243+
return purgeStoreByRepo(db, RANKED_CANDIDATES_PURGE_SPEC, normalizeRepoFullName(repoFullName, "invalid_repo_full_name"));
244+
},
228245
close() {
229246
db.close();
230247
},

packages/loopover-miner/lib/replay-snapshot.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { join } from "node:path";
22
import { removeWorktree } from "@loopover/engine";
33
import type { WorktreeExecFn, WorktreeRemoveResult } from "@loopover/engine";
44
import { openLocalStoreAdapter, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js";
5+
import { REPLAY_SNAPSHOT_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";
56

67
// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports:
78
// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive
@@ -51,6 +52,8 @@ export type ReplaySnapshotStore = {
5152
dbPath: string;
5253
getSnapshot(repoFullName: string, commitSha: string): ReplaySnapshot | null;
5354
saveSnapshot(snapshot: Omit<ReplaySnapshot, "exportedAt">): ReplaySnapshot;
55+
/** Delete every cached snapshot row for one repo (#8009); returns the number of rows removed. */
56+
purgeByRepo(repoFullName: string): number;
5457
close(): void;
5558
};
5659

@@ -272,6 +275,14 @@ export function openReplaySnapshotStore(dbPath: string = resolveReplaySnapshotDb
272275
dbPath: resolvedPath,
273276
getSnapshot,
274277
saveSnapshot,
278+
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
279+
* `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo against
280+
* the raw handle (the #7175 driver seam covers this store's own CRUD, not the shared maintenance helpers),
281+
* exactly like the other repo-scoped stores. Removes only DB rows — exported worktrees are transient files
282+
* the snapshot merely references, cleaned up by removeReplaySnapshotWorktree in their own lifecycle. */
283+
purgeByRepo(repoFullName: string): number {
284+
return purgeStoreByRepo(db, REPLAY_SNAPSHOT_PURGE_SPEC, normalizeRepoFullName(repoFullName));
285+
},
275286
close() {
276287
db.close();
277288
},

packages/loopover-miner/lib/store-maintenance.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ export const GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC: LedgerPurgeSpec = { table: "go
5858
* column, exactly like `attempt-log.js`). */
5959
export const POLICY_VERDICT_CACHE_PURGE_SPEC: LedgerPurgeSpec = { table: "policy_verdict_cache", repoColumn: "repo_scope" };
6060

61+
/** Three more repo-scoped stores the #5564/#7091/#6987 sweeps missed (#8009), same `repoColumn` shape and same
62+
* internal-constant-only discipline. ranked-candidates is a wholesale-replaced snapshot, but its rows persist
63+
* between discover runs; replay_snapshots embeds commit SHAs and README content. deny-hook-synthesis's live
64+
* table is always `deny_rule_proposals` (`deny_rule_proposals_v2` exists only transiently mid-rebuild inside
65+
* its forge-scope migration, never at rest, so one spec covers both pre- and post-migration files), and — like
66+
* `governor_reputation_history` above — it is purged on `repo_full_name` alone (its key is composite with
67+
* `api_base_url`), so a right-to-be-forgotten sweep clears the repo across every forge host it was recorded
68+
* against, not just the default one. */
69+
export const RANKED_CANDIDATES_PURGE_SPEC: LedgerPurgeSpec = { table: "miner_ranked_candidates", repoColumn: "repo_full_name" };
70+
export const REPLAY_SNAPSHOT_PURGE_SPEC: LedgerPurgeSpec = { table: "replay_snapshots", repoColumn: "repo_full_name" };
71+
export const DENY_HOOK_SYNTHESIS_PURGE_SPEC: LedgerPurgeSpec = { table: "deny_rule_proposals", repoColumn: "repo_full_name" };
72+
6173
export type StoreIntegrityResult = { name: string; ok: boolean; detail: string };
6274
export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number };
6375

test/unit/miner-deny-hook-synthesis.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,27 @@ describe("initDenyHookSynthesisStore() (#4522)", () => {
231231
expect(() => store.setProposalStatus("acme/widgets", "path:abc", "bogus")).toThrow("invalid_proposal_status");
232232
});
233233

234+
it("purgeByRepo sweeps the repo's proposals under EVERY forge host and leaves other repos intact (#8009)", () => {
235+
const store = tempStore();
236+
const history = [
237+
{ blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] },
238+
{ blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] },
239+
];
240+
store.refreshProposals("acme/widgets", history, {}, "https://api.github.com");
241+
store.refreshProposals("acme/widgets", history, {}, "https://gitlab.example/api");
242+
store.refreshProposals("acme/other", history);
243+
244+
// Filters on repo_full_name alone: one proposal per forge host = 2 rows removed.
245+
expect(store.purgeByRepo("acme/widgets")).toBe(2);
246+
expect(store.listProposals("acme/widgets", "https://api.github.com")).toEqual([]);
247+
expect(store.listProposals("acme/widgets", "https://gitlab.example/api")).toEqual([]);
248+
expect(store.listProposals("acme/other")).toHaveLength(1);
249+
});
250+
251+
it("purgeByRepo returns 0 when the repo has no proposals (#8009)", () => {
252+
expect(tempStore().purgeByRepo("acme/widgets")).toBe(0);
253+
});
254+
234255
it("migrates an existing pre-#5563 file, backfilling api_base_url and preserving every row", () => {
235256
const dir = mkdtempSync(join(tmpdir(), "miner-deny-hook-synthesis-legacy-"));
236257
tempDirs.push(dir);

test/unit/miner-discover-cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,7 @@ describe("runDiscover (#4247)", () => {
14481448
dbPath: ":memory:",
14491449
saveRankedCandidates,
14501450
listRankedCandidates: () => [],
1451+
purgeByRepo: () => 0,
14511452
close: () => undefined,
14521453
}),
14531454
fetchCandidateIssuesWithSummary,

0 commit comments

Comments
 (0)