Skip to content

Commit fe12850

Browse files
committed
fix(fk-cascade-guard): fail --check on stale baseline entries to prevent rot
Addresses Devin review on #4618: --check only failed on NEW violations, so a baselined FK that later gets indexed leaves a stale entry that is never pruned. Because entries match by fingerprint, a later change that re-removes that index would be silently re-accepted by the leftover entry (the run-ops guard had the same drift). --check now also fails on baseline fingerprints no longer present in the schema, forcing a regenerate when a baselined FK is fixed or removed.
1 parent 02a93d4 commit fe12850

1 file changed

Lines changed: 56 additions & 22 deletions

File tree

apps/webapp/scripts/fkCascadeIndexGuard.ts

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
*
2222
* A baseline entry is matched on its key AND its onDelete action AND its ordered fkColumns, so
2323
* changing a relation's FK column or flipping Cascade/SetNull re-triggers the guard rather than
24-
* silently inheriting the old acceptance.
24+
* silently inheriting the old acceptance. `--check` also fails on STALE baseline entries whose
25+
* fingerprint no longer appears in the schema (the FK got indexed or removed), so the baseline
26+
* can't rot: a fixed entry must be pruned by regenerating, otherwise a later change that removes
27+
* the index would be silently re-accepted by the leftover entry.
2528
*
2629
* Modes (mirrors guard:runops-legacy):
2730
* tsx ./scripts/fkCascadeIndexGuard.ts # regenerate the baseline
@@ -205,7 +208,9 @@ function serializeBaseline(comment: string, violations: Violation[]): string {
205208
return lines.join("\n") + "\n";
206209
}
207210

208-
function loadBaselineFingerprints(): Set<string> {
211+
type BaselineEntry = { key: string; onDelete: string; fkColumns: string[] };
212+
213+
function loadBaseline(): BaselineEntry[] {
209214
let parsed: unknown;
210215
try {
211216
parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
@@ -218,7 +223,7 @@ function loadBaselineFingerprints(): Set<string> {
218223
console.error(`baseline is missing a "violations" array: ${BASELINE_PATH}.`);
219224
process.exit(2);
220225
}
221-
const fps = new Set<string>();
226+
const entries: BaselineEntry[] = [];
222227
for (const v of violations) {
223228
const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown };
224229
if (
@@ -231,9 +236,13 @@ function loadBaselineFingerprints(): Set<string> {
231236
console.error(`baseline has a malformed entry: ${JSON.stringify(v)}`);
232237
process.exit(2);
233238
}
234-
fps.add(fingerprint(entry.key, entry.onDelete, entry.fkColumns as string[]));
239+
entries.push({
240+
key: entry.key,
241+
onDelete: entry.onDelete,
242+
fkColumns: entry.fkColumns as string[],
243+
});
235244
}
236-
return fps;
245+
return entries;
237246
}
238247

239248
function main() {
@@ -264,30 +273,55 @@ function main() {
264273
console.error(`baseline missing: ${BASELINE_PATH}. Run without --check to generate it.`);
265274
process.exit(2);
266275
}
267-
const baselined = loadBaselineFingerprints();
268-
const fresh = all.filter((v) => !baselined.has(fingerprint(v.key, v.onDelete, v.fkColumns)));
276+
const baselineEntries = loadBaseline();
277+
const baselinedFps = new Set(
278+
baselineEntries.map((e) => fingerprint(e.key, e.onDelete, e.fkColumns))
279+
);
280+
const currentFps = new Set(all.map((v) => fingerprint(v.key, v.onDelete, v.fkColumns)));
269281

270-
if (fresh.length === 0) {
271-
console.log(`fk-cascade-index guard: OK (${baselined.size} baselined, 0 new).`);
282+
const fresh = all.filter((v) => !baselinedFps.has(fingerprint(v.key, v.onDelete, v.fkColumns)));
283+
const stale = baselineEntries.filter(
284+
(e) => !currentFps.has(fingerprint(e.key, e.onDelete, e.fkColumns))
285+
);
286+
287+
if (fresh.length === 0 && stale.length === 0) {
288+
console.log(`fk-cascade-index guard: OK (${baselinedFps.size} baselined, 0 new, 0 stale).`);
272289
return;
273290
}
274291

275-
console.error(
276-
`\nfk-cascade-index guard: ${fresh.length} new unindexed cascade FK column(s).\n` +
277-
`Each fires a full sequential scan of the child table on every parent delete.\n`
278-
);
279-
for (const v of fresh) {
292+
if (fresh.length > 0) {
280293
console.error(
281-
` ${v.schema}: ${v.model}.${v.relationField} ` +
282-
`(onDelete: ${v.onDelete}, fk: [${v.fkColumns.join(", ")}])`
294+
`\nfk-cascade-index guard: ${fresh.length} new unindexed cascade FK column(s).\n` +
295+
`Each fires a full sequential scan of the child table on every parent delete.\n`
296+
);
297+
for (const v of fresh) {
298+
console.error(
299+
` ${v.schema}: ${v.model}.${v.relationField} ` +
300+
`(onDelete: ${v.onDelete}, fk: [${v.fkColumns.join(", ")}])`
301+
);
302+
}
303+
console.error(
304+
`\nFix: add @@index([${fresh[0].fkColumns[0]}]) (or a composite leading with it) to the ` +
305+
`child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` +
306+
`If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` +
307+
`and explain why in the PR.\n`
283308
);
284309
}
285-
console.error(
286-
`\nFix: add @@index([${fresh[0].fkColumns[0]}]) (or a composite leading with it) to the ` +
287-
`child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` +
288-
`If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` +
289-
`and explain why in the PR.\n`
290-
);
310+
311+
if (stale.length > 0) {
312+
console.error(
313+
`\nfk-cascade-index guard: ${stale.length} stale baseline entr${stale.length === 1 ? "y" : "ies"} ` +
314+
`no longer present in the schema (now indexed or removed):\n`
315+
);
316+
for (const e of stale) {
317+
console.error(` ${e.key} (onDelete: ${e.onDelete}, fk: [${e.fkColumns.join(", ")}])`);
318+
}
319+
console.error(
320+
`\nRegenerate the baseline so a later change can't silently re-accept these:\n` +
321+
` pnpm --filter webapp run guard:fk-cascade-index\n`
322+
);
323+
}
324+
291325
process.exit(1);
292326
}
293327

0 commit comments

Comments
 (0)