Files
triggerdotdev--trigger.dev/apps/webapp/scripts/fkCascadeIndexGuard.ts
Eric Allam 4c21af8669 feat(webapp): CI guard for unindexed onDelete cascade FK columns (#4618)
## What

A relation with `onDelete: Cascade | SetNull` whose child FK column has
no index makes every parent delete fire a cascade that sequentially
scans the whole child table. That has shipped three times recently and
had to be fixed after the fact (#4554 `ProjectAlert.channelId`, #4555
`EnvironmentVariableValue.valueReferenceId`, #4588
`PersonalAccessToken.userId`).

This adds a schema-aware CI guard that catches the next one before it
merges.

## How

`apps/webapp/scripts/fkCascadeIndexGuard.ts` parses both Prisma schemas
(`@trigger.dev/database`, `@internal/run-ops-database`) and flags any
`onDelete: Cascade | SetNull` relation whose leading FK scalar is not
the leading column of some index (`@@index` / `@@unique` / `@@id` /
field-level `@id`/`@unique`) on the child model. A leading FK column
lets the cascade's `WHERE fk = $1` use the index instead of a seq scan.

It is modeled on the existing `runOpsLegacyGuard` (same `--check` gate,
same baseline-regenerate pattern), and it is lighter: it only reads
`schema.prisma` as text, so its CI job needs no Prisma client generation
and no raised heap.

## Why a baseline, not a hard rule

Not every unindexed cascade FK is a live bug. When the parent is only
ever soft-deleted, the cascade never fires, so the missing index is
harmless. Hard vs soft delete lives in application code
(`parent.delete()` vs `parent.update({ deletedAt })`), not in the
schema, and a `deletedAt` column proves neither direction. So the guard
makes no such judgment: it flags every unindexed cascade FK uniformly
and carries a baseline of the 72 currently-accepted cases. Only
violations **not** in the baseline fail `--check`.

The value is the forcing function: a newly added cascade FK stops CI and
makes the author answer "is the parent ever hard-deleted?" Add the index
if yes; regenerate the baseline with a reason if no.

## Wiring

- `apps/webapp/package.json`: `guard:fk-cascade-index` script
(regenerate with no args, gate with `-- --check`).
- `.github/workflows/fk-cascade-guard.yml`: the reusable workflow.
- `.github/workflows/pr_checks.yml`: runs on webapp-affecting changes,
aggregated into `all-checks`.

## Verification

- The three already-fixed columns are correctly seen as indexed (absent
from the baseline).
- `--check` passes on the current schemas (72 baselined, 0 new).
- A synthetic new unindexed cascade FK fails with exit 1 and an
actionable message.
- Adding `@@index([fk])`, or a composite leading with the FK, clears it.
No false positives.
- `oxfmt` and `oxlint` clean on the new script.

## Rollback

Pure tooling addition, no runtime code, no schema or data change. Revert
to remove.
2026-08-14 13:55:38 +01:00

329 lines
11 KiB
TypeScript

/**
* FK-cascade index guard — a schema-aware fence against the class of bug fixed in
* #4554, #4555, #4588: a relation with `onDelete: Cascade | SetNull` whose child FK column
* has no index, so every parent delete fires a cascade that sequentially scans the whole
* child table.
*
* Rule: for every relation field carrying `onDelete: Cascade | SetNull` (which always sits on
* the child side, alongside `fields: [...]`), the FIRST FK scalar must be the LEADING column
* of some index on that model (`@@index`, `@@unique`, `@@id`, or a field-level `@id`/`@unique`).
* A leading FK column lets the cascade's `WHERE fk = $1` use the index instead of a seq scan.
*
* Not every unindexed cascade FK is a live bug: when the parent is only ever SOFT-deleted, the
* cascade never fires, so the missing index is harmless. The guard CANNOT tell hard- from
* soft-delete — that lives in application code (`parent.delete()` vs `parent.update({ deletedAt })`),
* not in the schema, and a `deletedAt` column proves neither direction. So the guard makes no
* such judgment: it flags every unindexed cascade FK uniformly and — exactly like
* runOpsLegacyGuard — carries a BASELINE of the currently accepted ones. Only violations NOT in
* the baseline fail `--check`. The point is the forcing function: a new cascade FK stops CI and
* makes a human answer "is the parent ever hard-deleted?" — add the index if yes, regenerate the
* baseline with a reason if no.
*
* A baseline entry is matched on its key AND its onDelete action AND its ordered fkColumns, so
* changing a relation's FK column or flipping Cascade/SetNull re-triggers the guard rather than
* silently inheriting the old acceptance. `--check` also fails on STALE baseline entries whose
* fingerprint no longer appears in the schema (the FK got indexed or removed), so the baseline
* can't rot: a fixed entry must be pruned by regenerating, otherwise a later change that removes
* the index would be silently re-accepted by the leftover entry.
*
* Modes (mirrors guard:runops-legacy):
* tsx ./scripts/fkCascadeIndexGuard.ts # regenerate the baseline
* tsx ./scripts/fkCascadeIndexGuard.ts --check # CI gate: exit 1 on any un-baselined violation
*/
import * as fs from "node:fs";
import * as path from "node:path";
function findRepoRoot(start: string): string {
let dir = path.resolve(start);
for (;;) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
const parent = path.dirname(dir);
if (parent === dir)
throw new Error("Could not locate repo root (pnpm-workspace.yaml not found)");
dir = parent;
}
}
const REPO_ROOT = findRepoRoot(process.cwd());
const SCHEMAS = [
{
label: "control-plane",
file: path.join(REPO_ROOT, "internal-packages", "database", "prisma", "schema.prisma"),
},
{
label: "run-ops",
file: path.join(REPO_ROOT, "internal-packages", "run-ops-database", "prisma", "schema.prisma"),
},
];
const BASELINE_PATH = path.join(
REPO_ROOT,
"apps",
"webapp",
"scripts",
"fk-cascade-index-baseline.json"
);
type Violation = {
key: string;
schema: string;
model: string;
relationField: string;
fkColumns: string[];
onDelete: string;
};
function fingerprint(key: string, onDelete: string, fkColumns: string[]): string {
return `${key}::${onDelete}::${fkColumns.join(",")}`;
}
function scrubLine(line: string): { code: string; masked: string } {
let code = "";
let masked = "";
let inString = false;
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inString) {
code += ch;
masked += " ";
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
code += ch;
masked += " ";
continue;
}
if (ch === "/" && line[i + 1] === "/") break;
code += ch;
masked += ch;
}
return { code, masked };
}
function toLogicalLines(body: string): string[] {
const out: string[] = [];
let buf = "";
let depth = 0;
for (const raw of body.split("\n")) {
const { code, masked } = scrubLine(raw);
const trimmed = code.trim();
if (trimmed === "") continue;
buf = buf === "" ? trimmed : `${buf} ${trimmed}`;
for (const ch of masked) {
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
}
if (depth === 0) {
out.push(buf);
buf = "";
}
}
if (buf !== "") out.push(buf);
return out;
}
function leadingColumn(bracketBody: string): string | null {
const first = bracketBody.split(",")[0]?.trim();
if (!first) return null;
const m = /^([A-Za-z_][A-Za-z0-9_]*)/.exec(first);
return m ? m[1] : null;
}
function allColumns(bracketBody: string): string[] {
return bracketBody
.split(",")
.map((c) => /^\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(c)?.[1])
.filter((c): c is string => Boolean(c));
}
function scanSchema(label: string, file: string): Violation[] {
const text = fs.readFileSync(file, "utf8");
const violations: Violation[] = [];
const modelRe = /^model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)^\}/gm;
let mm: RegExpExecArray | null;
while ((mm = modelRe.exec(text))) {
const model = mm[1];
const lines = toLogicalLines(mm[2]);
const leadingIndexed = new Set<string>();
for (const line of lines) {
const block = /^@@(index|unique|id)\(\s*\[([^\]]*)\]/.exec(line);
if (block) {
const lead = leadingColumn(block[2]);
if (lead) leadingIndexed.add(lead);
continue;
}
const fieldDecl = /^([A-Za-z_][A-Za-z0-9_]*)\s+\S+.*@(id|unique)\b/.exec(line);
if (fieldDecl && !line.startsWith("@@")) {
leadingIndexed.add(fieldDecl[1]);
}
}
for (const line of lines) {
if (!line.includes("@relation(")) continue;
const onDelete = /onDelete:\s*(Cascade|SetNull)/.exec(line);
if (!onDelete) continue;
const fields = /fields:\s*\[([^\]]*)\]/.exec(line);
if (!fields) continue;
const fkColumns = allColumns(fields[1]);
const lead = fkColumns[0];
if (!lead) continue;
const relationField = /^([A-Za-z_][A-Za-z0-9_]*)/.exec(line)?.[1] ?? "?";
if (!leadingIndexed.has(lead)) {
violations.push({
key: `${label}:${model}.${relationField}`,
schema: label,
model,
relationField,
fkColumns,
onDelete: onDelete[1],
});
}
}
}
return violations;
}
function serializeBaseline(comment: string, violations: Violation[]): string {
const lines: string[] = ["{", ` "_comment": ${JSON.stringify(comment)},`, ` "violations": [`];
violations.forEach((v, i) => {
const trailer = i < violations.length - 1 ? "," : "";
lines.push(
" {",
` "key": ${JSON.stringify(v.key)},`,
` "onDelete": ${JSON.stringify(v.onDelete)},`,
` "fkColumns": ${JSON.stringify(v.fkColumns)}`,
` }${trailer}`
);
});
lines.push(" ]", "}");
return lines.join("\n") + "\n";
}
type BaselineEntry = { key: string; onDelete: string; fkColumns: string[] };
function loadBaseline(): BaselineEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
} catch {
console.error(`baseline is not valid JSON: ${BASELINE_PATH}. Regenerate it without --check.`);
process.exit(2);
}
const violations = (parsed as { violations?: unknown }).violations;
if (!Array.isArray(violations)) {
console.error(`baseline is missing a "violations" array: ${BASELINE_PATH}.`);
process.exit(2);
}
const entries: BaselineEntry[] = [];
for (const v of violations) {
const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown };
if (
typeof entry.key !== "string" ||
(entry.onDelete !== "Cascade" && entry.onDelete !== "SetNull") ||
!Array.isArray(entry.fkColumns) ||
entry.fkColumns.length === 0 ||
!entry.fkColumns.every((c) => typeof c === "string")
) {
console.error(`baseline has a malformed entry: ${JSON.stringify(v)}`);
process.exit(2);
}
entries.push({
key: entry.key,
onDelete: entry.onDelete,
fkColumns: entry.fkColumns as string[],
});
}
return entries;
}
function main() {
const check = process.argv.includes("--check");
const all: Violation[] = [];
for (const s of SCHEMAS) {
if (!fs.existsSync(s.file)) {
console.error(`schema not found: ${s.file}`);
process.exit(2);
}
all.push(...scanSchema(s.label, s.file));
}
all.sort((a, b) => a.key.localeCompare(b.key));
if (!check) {
const comment =
"Accepted unindexed cascade/SetNull FK columns. Each is either a soft-deleted parent " +
"(cascade never fires) or an accepted risk. Adding a NEW relation here should be a " +
"deliberate choice with a reason in the PR. Prefer adding the index instead.";
fs.writeFileSync(BASELINE_PATH, serializeBaseline(comment, all));
console.log(`Wrote baseline with ${all.length} accepted unindexed cascade FK(s).`);
console.log(` -> ${path.relative(REPO_ROOT, BASELINE_PATH)}`);
return;
}
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`baseline missing: ${BASELINE_PATH}. Run without --check to generate it.`);
process.exit(2);
}
const baselineEntries = loadBaseline();
const baselinedFps = new Set(
baselineEntries.map((e) => fingerprint(e.key, e.onDelete, e.fkColumns))
);
const currentFps = new Set(all.map((v) => fingerprint(v.key, v.onDelete, v.fkColumns)));
const fresh = all.filter((v) => !baselinedFps.has(fingerprint(v.key, v.onDelete, v.fkColumns)));
const stale = baselineEntries.filter(
(e) => !currentFps.has(fingerprint(e.key, e.onDelete, e.fkColumns))
);
if (fresh.length === 0 && stale.length === 0) {
console.log(`fk-cascade-index guard: OK (${baselinedFps.size} baselined, 0 new, 0 stale).`);
return;
}
if (fresh.length > 0) {
console.error(
`\nfk-cascade-index guard: ${fresh.length} new unindexed cascade FK column(s).\n` +
`Each fires a full sequential scan of the child table on every parent delete.\n`
);
for (const v of fresh) {
console.error(
` ${v.schema}: ${v.model}.${v.relationField} ` +
`(onDelete: ${v.onDelete}, fk: [${v.fkColumns.join(", ")}])`
);
}
console.error(
`\nFix: add @@index([${fresh[0].fkColumns[0]}]) (or a composite leading with it) to the ` +
`child model, in its own migration with CREATE INDEX CONCURRENTLY IF NOT EXISTS.\n` +
`If the parent is only ever soft-deleted (cascade never fires), regenerate the baseline ` +
`and explain why in the PR.\n`
);
}
if (stale.length > 0) {
console.error(
`\nfk-cascade-index guard: ${stale.length} stale baseline entr${stale.length === 1 ? "y" : "ies"} ` +
`no longer present in the schema (now indexed or removed):\n`
);
for (const e of stale) {
console.error(` ${e.key} (onDelete: ${e.onDelete}, fk: [${e.fkColumns.join(", ")}])`);
}
console.error(
`\nRegenerate the baseline so a later change can't silently re-accept these:\n` +
` pnpm --filter webapp run guard:fk-cascade-index\n`
);
}
process.exit(1);
}
main();