From 09f4083097b01ec98b0caf34473b35795dfa8449 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Aug 2026 12:04:49 +0100 Subject: [PATCH 1/4] feat(webapp): CI guard for unindexed onDelete cascade FK columns 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. This shipped three times recently (ProjectAlert.channelId, EnvironmentVariableValue.valueReferenceId, PersonalAccessToken.userId). Adds a schema-aware guard (modeled on runOpsLegacyGuard) that parses both Prisma schemas and flags any cascade/SetNull FK whose leading scalar is not the leading column of an index. Whether a missing index is a live bug depends on whether the parent is hard- or soft-deleted, which is not in the schema, so the guard carries a baseline of the 72 currently-accepted cases and only fails --check on new un-baselined violations. Wired into pr_checks as fk-cascade-guard. --- .github/workflows/fk-cascade-guard.yml | 35 ++ .github/workflows/pr_checks.yml | 7 + apps/webapp/package.json | 1 + .../scripts/fk-cascade-index-baseline.json | 509 ++++++++++++++++++ apps/webapp/scripts/fkCascadeIndexGuard.ts | 202 +++++++ 5 files changed, 754 insertions(+) create mode 100644 .github/workflows/fk-cascade-guard.yml create mode 100644 apps/webapp/scripts/fk-cascade-index-baseline.json create mode 100644 apps/webapp/scripts/fkCascadeIndexGuard.ts diff --git a/.github/workflows/fk-cascade-guard.yml b/.github/workflows/fk-cascade-guard.yml new file mode 100644 index 0000000000..eae272354d --- /dev/null +++ b/.github/workflows/fk-cascade-guard.yml @@ -0,0 +1,35 @@ +name: "🛡️ FK Cascade Index Guard" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + fk-cascade-guard: + runs-on: warp-ubuntu-latest-x64-16x + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.33.2 + + - name: ⎔ Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: "pnpm" + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 🛡️ FK cascade index guard + run: pnpm --filter webapp run guard:fk-cascade-index -- --check diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 7de0531946..f8013c50d1 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -58,6 +58,7 @@ jobs: - '.github/workflows/unit-tests-webapp.yml' - '.github/workflows/e2e-webapp.yml' - '.github/workflows/runops-guard.yml' + - '.github/workflows/fk-cascade-guard.yml' - '.configs/**' - 'package.json' - 'pnpm-lock.yaml' @@ -150,6 +151,11 @@ jobs: if: needs.changes.outputs.webapp == 'true' uses: ./.github/workflows/runops-guard.yml + fk-cascade-guard: + needs: changes + if: needs.changes.outputs.webapp == 'true' + uses: ./.github/workflows/fk-cascade-guard.yml + webapp: needs: changes if: needs.changes.outputs.webapp == 'true' @@ -206,6 +212,7 @@ jobs: - code-quality - typecheck - runops-guard + - fk-cascade-guard - webapp - e2e-webapp - packages diff --git a/apps/webapp/package.json b/apps/webapp/package.json index a9a670f77c..16f1a4eb87 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -17,6 +17,7 @@ "start:local": "cross-env node --max-old-space-size=8192 ./build/server.js", "typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json", "guard:runops-legacy": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsx ./scripts/runOpsLegacyGuard.ts", + "guard:fk-cascade-index": "tsx ./scripts/fkCascadeIndexGuard.ts", "db:seed": "tsx seed.ts", "db:seed:ai-spans": "tsx seed-ai-spans.mts", "db:seed:queue-metrics": "tsx seed-queue-metrics.mts", diff --git a/apps/webapp/scripts/fk-cascade-index-baseline.json b/apps/webapp/scripts/fk-cascade-index-baseline.json new file mode 100644 index 0000000000..ae9564a88c --- /dev/null +++ b/apps/webapp/scripts/fk-cascade-index-baseline.json @@ -0,0 +1,509 @@ +{ + "_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.", + "violations": [ + { + "key": "control-plane:ApiKey.createdBy", + "onDelete": "SetNull", + "fkColumns": [ + "createdByUserId" + ] + }, + { + "key": "control-plane:AuthorizationCode.personalAccessToken", + "onDelete": "Cascade", + "fkColumns": [ + "personalAccessTokenId" + ] + }, + { + "key": "control-plane:BackgroundWorker.workerGroup", + "onDelete": "SetNull", + "fkColumns": [ + "workerGroupId" + ] + }, + { + "key": "control-plane:BackgroundWorkerTask.file", + "onDelete": "Cascade", + "fkColumns": [ + "fileId" + ] + }, + { + "key": "control-plane:BackgroundWorkerTask.queue", + "onDelete": "SetNull", + "fkColumns": [ + "queueId" + ] + }, + { + "key": "control-plane:BulkActionGroup.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:BulkActionGroup.user", + "onDelete": "SetNull", + "fkColumns": [ + "userId" + ] + }, + { + "key": "control-plane:BulkActionItem.destinationRun", + "onDelete": "Cascade", + "fkColumns": [ + "destinationRunId" + ] + }, + { + "key": "control-plane:BulkActionItem.sourceRun", + "onDelete": "Cascade", + "fkColumns": [ + "sourceRunId" + ] + }, + { + "key": "control-plane:Checkpoint.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:Checkpoint.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": [ + "runtimeEnvironmentId" + ] + }, + { + "key": "control-plane:CheckpointRestoreEvent.attempt", + "onDelete": "Cascade", + "fkColumns": [ + "attemptId" + ] + }, + { + "key": "control-plane:CheckpointRestoreEvent.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:CheckpointRestoreEvent.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": [ + "runtimeEnvironmentId" + ] + }, + { + "key": "control-plane:CustomerQuery.environment", + "onDelete": "Cascade", + "fkColumns": [ + "environmentId" + ] + }, + { + "key": "control-plane:CustomerQuery.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:CustomerQuery.user", + "onDelete": "SetNull", + "fkColumns": [ + "userId" + ] + }, + { + "key": "control-plane:ErrorGroupState.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:ErrorGroupState.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:LlmPrice.pricingTier", + "onDelete": "Cascade", + "fkColumns": [ + "pricingTierId" + ] + }, + { + "key": "control-plane:MetricsDashboard.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:MetricsDashboard.owner", + "onDelete": "SetNull", + "fkColumns": [ + "ownerId" + ] + }, + { + "key": "control-plane:OrganizationIntegration.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:OrganizationIntegration.tokenReference", + "onDelete": "Cascade", + "fkColumns": [ + "tokenReferenceId" + ] + }, + { + "key": "control-plane:OrganizationProjectIntegration.organizationIntegration", + "onDelete": "Cascade", + "fkColumns": [ + "organizationIntegrationId" + ] + }, + { + "key": "control-plane:OrgMember.user", + "onDelete": "Cascade", + "fkColumns": [ + "userId" + ] + }, + { + "key": "control-plane:OrgMemberInvite.inviter", + "onDelete": "Cascade", + "fkColumns": [ + "inviterId" + ] + }, + { + "key": "control-plane:PlatformNotification.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:PlatformNotification.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:PlatformNotification.user", + "onDelete": "Cascade", + "fkColumns": [ + "userId" + ] + }, + { + "key": "control-plane:PlatformNotificationInteraction.user", + "onDelete": "Cascade", + "fkColumns": [ + "userId" + ] + }, + { + "key": "control-plane:PlaygroundConversation.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:PlaygroundConversation.run", + "onDelete": "SetNull", + "fkColumns": [ + "runId" + ] + }, + { + "key": "control-plane:Project.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:ProjectAlert.environment", + "onDelete": "Cascade", + "fkColumns": [ + "environmentId" + ] + }, + { + "key": "control-plane:ProjectAlert.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:ProjectAlert.workerDeployment", + "onDelete": "Cascade", + "fkColumns": [ + "workerDeploymentId" + ] + }, + { + "key": "control-plane:ProjectAlertChannel.integration", + "onDelete": "SetNull", + "fkColumns": [ + "integrationId" + ] + }, + { + "key": "control-plane:ProjectAlertStorage.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:Prompt.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:RuntimeEnvironment.currentSession", + "onDelete": "SetNull", + "fkColumns": [ + "currentSessionId" + ] + }, + { + "key": "control-plane:RuntimeEnvironment.orgMember", + "onDelete": "SetNull", + "fkColumns": [ + "orgMemberId" + ] + }, + { + "key": "control-plane:RuntimeEnvironmentSession.environment", + "onDelete": "Cascade", + "fkColumns": [ + "environmentId" + ] + }, + { + "key": "control-plane:TaskIdentifier.currentWorker", + "onDelete": "SetNull", + "fkColumns": [ + "currentWorkerId" + ] + }, + { + "key": "control-plane:TaskIdentifier.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:TaskQueue.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:TaskRun.batch", + "onDelete": "SetNull", + "fkColumns": [ + "batchId" + ] + }, + { + "key": "control-plane:TaskRun.parentTaskRunAttempt", + "onDelete": "SetNull", + "fkColumns": [ + "parentTaskRunAttemptId" + ] + }, + { + "key": "control-plane:TaskRun.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:TaskRun.rootTaskRun", + "onDelete": "SetNull", + "fkColumns": [ + "rootTaskRunId" + ] + }, + { + "key": "control-plane:TaskRunAttempt.backgroundWorker", + "onDelete": "Cascade", + "fkColumns": [ + "backgroundWorkerId" + ] + }, + { + "key": "control-plane:TaskRunAttempt.backgroundWorkerTask", + "onDelete": "Cascade", + "fkColumns": [ + "backgroundWorkerTaskId" + ] + }, + { + "key": "control-plane:TaskRunAttempt.queue", + "onDelete": "Cascade", + "fkColumns": [ + "queueId" + ] + }, + { + "key": "control-plane:TaskRunAttempt.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": [ + "runtimeEnvironmentId" + ] + }, + { + "key": "control-plane:TaskRunCheckpoint.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:TaskRunCheckpoint.runtimeEnvironment", + "onDelete": "Cascade", + "fkColumns": [ + "runtimeEnvironmentId" + ] + }, + { + "key": "control-plane:TaskRunNumberCounter.environment", + "onDelete": "Cascade", + "fkColumns": [ + "environmentId" + ] + }, + { + "key": "control-plane:TaskRunWaitpoint.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:Waitpoint.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:WaitpointTag.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:WorkerDeployment.triggeredBy", + "onDelete": "SetNull", + "fkColumns": [ + "triggeredById" + ] + }, + { + "key": "control-plane:WorkerDeploymentPromotion.deployment", + "onDelete": "Cascade", + "fkColumns": [ + "deploymentId" + ] + }, + { + "key": "control-plane:WorkerInstance.deployment", + "onDelete": "SetNull", + "fkColumns": [ + "deploymentId" + ] + }, + { + "key": "control-plane:WorkerInstance.environment", + "onDelete": "Cascade", + "fkColumns": [ + "environmentId" + ] + }, + { + "key": "control-plane:WorkerInstance.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:WorkerInstance.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "control-plane:WorkerInstanceGroup.organization", + "onDelete": "Cascade", + "fkColumns": [ + "organizationId" + ] + }, + { + "key": "control-plane:WorkerInstanceGroup.project", + "onDelete": "Cascade", + "fkColumns": [ + "projectId" + ] + }, + { + "key": "run-ops:CheckpointRestoreEvent.attempt", + "onDelete": "Cascade", + "fkColumns": [ + "attemptId" + ] + }, + { + "key": "run-ops:TaskRun.batch", + "onDelete": "SetNull", + "fkColumns": [ + "batchId" + ] + }, + { + "key": "run-ops:TaskRun.parentTaskRunAttempt", + "onDelete": "SetNull", + "fkColumns": [ + "parentTaskRunAttemptId" + ] + }, + { + "key": "run-ops:TaskRun.rootTaskRun", + "onDelete": "SetNull", + "fkColumns": [ + "rootTaskRunId" + ] + } + ] +} diff --git a/apps/webapp/scripts/fkCascadeIndexGuard.ts b/apps/webapp/scripts/fkCascadeIndexGuard.ts new file mode 100644 index 0000000000..fe8ce4f659 --- /dev/null +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -0,0 +1,202 @@ +/** + * 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. + * + * 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 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 body = mm[2]; + const lines = body.split("\n"); + + const leadingIndexed = new Set(); + for (const raw of lines) { + const line = raw.trim(); + 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 raw of lines) { + const line = raw.trim(); + 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 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 baseline = { + _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.", + violations: all.map((v) => ({ + key: v.key, + onDelete: v.onDelete, + fkColumns: v.fkColumns, + })), + }; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + 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 baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as { + violations: { key: string }[]; + }; + const baselined = new Set(baseline.violations.map((v) => v.key)); + const fresh = all.filter((v) => !baselined.has(v.key)); + + if (fresh.length === 0) { + console.log(`fk-cascade-index guard: OK (${all.length} baselined, 0 new).`); + return; + } + + 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` + ); + process.exit(1); +} + +main(); From 78a214d090b502d01c9fd69bd4b355abc4b9ac0a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Aug 2026 12:22:01 +0100 Subject: [PATCH 2/4] fix(fk-cascade-guard): parse multiline relations, fingerprint baseline, oxfmt-clean output Addresses CodeRabbit review on #4618: - Reconstruct logical statements by balancing brackets so a relation whose onDelete/fields span multiple lines is no longer skipped. - Match baseline entries on key + onDelete + ordered fkColumns, so changing a relation's FK column or flipping Cascade/SetNull re-triggers the guard instead of inheriting the old acceptance. Exit 2 on malformed baseline JSON/shape. - Emit the baseline in oxfmt's format (inline short arrays) so regeneration stays code-quality clean. --- .../scripts/fk-cascade-index-baseline.json | 288 +++++------------- apps/webapp/scripts/fkCascadeIndexGuard.ts | 115 +++++-- 2 files changed, 163 insertions(+), 240 deletions(-) diff --git a/apps/webapp/scripts/fk-cascade-index-baseline.json b/apps/webapp/scripts/fk-cascade-index-baseline.json index ae9564a88c..cd2592f6d8 100644 --- a/apps/webapp/scripts/fk-cascade-index-baseline.json +++ b/apps/webapp/scripts/fk-cascade-index-baseline.json @@ -4,506 +4,362 @@ { "key": "control-plane:ApiKey.createdBy", "onDelete": "SetNull", - "fkColumns": [ - "createdByUserId" - ] + "fkColumns": ["createdByUserId"] }, { "key": "control-plane:AuthorizationCode.personalAccessToken", "onDelete": "Cascade", - "fkColumns": [ - "personalAccessTokenId" - ] + "fkColumns": ["personalAccessTokenId"] }, { "key": "control-plane:BackgroundWorker.workerGroup", "onDelete": "SetNull", - "fkColumns": [ - "workerGroupId" - ] + "fkColumns": ["workerGroupId"] }, { "key": "control-plane:BackgroundWorkerTask.file", "onDelete": "Cascade", - "fkColumns": [ - "fileId" - ] + "fkColumns": ["fileId"] }, { "key": "control-plane:BackgroundWorkerTask.queue", "onDelete": "SetNull", - "fkColumns": [ - "queueId" - ] + "fkColumns": ["queueId"] }, { "key": "control-plane:BulkActionGroup.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:BulkActionGroup.user", "onDelete": "SetNull", - "fkColumns": [ - "userId" - ] + "fkColumns": ["userId"] }, { "key": "control-plane:BulkActionItem.destinationRun", "onDelete": "Cascade", - "fkColumns": [ - "destinationRunId" - ] + "fkColumns": ["destinationRunId"] }, { "key": "control-plane:BulkActionItem.sourceRun", "onDelete": "Cascade", - "fkColumns": [ - "sourceRunId" - ] + "fkColumns": ["sourceRunId"] }, { "key": "control-plane:Checkpoint.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:Checkpoint.runtimeEnvironment", "onDelete": "Cascade", - "fkColumns": [ - "runtimeEnvironmentId" - ] + "fkColumns": ["runtimeEnvironmentId"] }, { "key": "control-plane:CheckpointRestoreEvent.attempt", "onDelete": "Cascade", - "fkColumns": [ - "attemptId" - ] + "fkColumns": ["attemptId"] }, { "key": "control-plane:CheckpointRestoreEvent.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:CheckpointRestoreEvent.runtimeEnvironment", "onDelete": "Cascade", - "fkColumns": [ - "runtimeEnvironmentId" - ] + "fkColumns": ["runtimeEnvironmentId"] }, { "key": "control-plane:CustomerQuery.environment", "onDelete": "Cascade", - "fkColumns": [ - "environmentId" - ] + "fkColumns": ["environmentId"] }, { "key": "control-plane:CustomerQuery.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:CustomerQuery.user", "onDelete": "SetNull", - "fkColumns": [ - "userId" - ] + "fkColumns": ["userId"] }, { "key": "control-plane:ErrorGroupState.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:ErrorGroupState.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:LlmPrice.pricingTier", "onDelete": "Cascade", - "fkColumns": [ - "pricingTierId" - ] + "fkColumns": ["pricingTierId"] }, { "key": "control-plane:MetricsDashboard.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:MetricsDashboard.owner", "onDelete": "SetNull", - "fkColumns": [ - "ownerId" - ] + "fkColumns": ["ownerId"] }, { "key": "control-plane:OrganizationIntegration.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:OrganizationIntegration.tokenReference", "onDelete": "Cascade", - "fkColumns": [ - "tokenReferenceId" - ] + "fkColumns": ["tokenReferenceId"] }, { "key": "control-plane:OrganizationProjectIntegration.organizationIntegration", "onDelete": "Cascade", - "fkColumns": [ - "organizationIntegrationId" - ] + "fkColumns": ["organizationIntegrationId"] }, { "key": "control-plane:OrgMember.user", "onDelete": "Cascade", - "fkColumns": [ - "userId" - ] + "fkColumns": ["userId"] }, { "key": "control-plane:OrgMemberInvite.inviter", "onDelete": "Cascade", - "fkColumns": [ - "inviterId" - ] + "fkColumns": ["inviterId"] }, { "key": "control-plane:PlatformNotification.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:PlatformNotification.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:PlatformNotification.user", "onDelete": "Cascade", - "fkColumns": [ - "userId" - ] + "fkColumns": ["userId"] }, { "key": "control-plane:PlatformNotificationInteraction.user", "onDelete": "Cascade", - "fkColumns": [ - "userId" - ] + "fkColumns": ["userId"] }, { "key": "control-plane:PlaygroundConversation.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:PlaygroundConversation.run", "onDelete": "SetNull", - "fkColumns": [ - "runId" - ] + "fkColumns": ["runId"] }, { "key": "control-plane:Project.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:ProjectAlert.environment", "onDelete": "Cascade", - "fkColumns": [ - "environmentId" - ] + "fkColumns": ["environmentId"] }, { "key": "control-plane:ProjectAlert.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:ProjectAlert.workerDeployment", "onDelete": "Cascade", - "fkColumns": [ - "workerDeploymentId" - ] + "fkColumns": ["workerDeploymentId"] }, { "key": "control-plane:ProjectAlertChannel.integration", "onDelete": "SetNull", - "fkColumns": [ - "integrationId" - ] + "fkColumns": ["integrationId"] }, { "key": "control-plane:ProjectAlertStorage.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:Prompt.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:RuntimeEnvironment.currentSession", "onDelete": "SetNull", - "fkColumns": [ - "currentSessionId" - ] + "fkColumns": ["currentSessionId"] }, { "key": "control-plane:RuntimeEnvironment.orgMember", "onDelete": "SetNull", - "fkColumns": [ - "orgMemberId" - ] + "fkColumns": ["orgMemberId"] }, { "key": "control-plane:RuntimeEnvironmentSession.environment", "onDelete": "Cascade", - "fkColumns": [ - "environmentId" - ] + "fkColumns": ["environmentId"] }, { "key": "control-plane:TaskIdentifier.currentWorker", "onDelete": "SetNull", - "fkColumns": [ - "currentWorkerId" - ] + "fkColumns": ["currentWorkerId"] }, { "key": "control-plane:TaskIdentifier.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:TaskQueue.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:TaskRun.batch", "onDelete": "SetNull", - "fkColumns": [ - "batchId" - ] + "fkColumns": ["batchId"] }, { "key": "control-plane:TaskRun.parentTaskRunAttempt", "onDelete": "SetNull", - "fkColumns": [ - "parentTaskRunAttemptId" - ] + "fkColumns": ["parentTaskRunAttemptId"] }, { "key": "control-plane:TaskRun.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:TaskRun.rootTaskRun", "onDelete": "SetNull", - "fkColumns": [ - "rootTaskRunId" - ] + "fkColumns": ["rootTaskRunId"] }, { "key": "control-plane:TaskRunAttempt.backgroundWorker", "onDelete": "Cascade", - "fkColumns": [ - "backgroundWorkerId" - ] + "fkColumns": ["backgroundWorkerId"] }, { "key": "control-plane:TaskRunAttempt.backgroundWorkerTask", "onDelete": "Cascade", - "fkColumns": [ - "backgroundWorkerTaskId" - ] + "fkColumns": ["backgroundWorkerTaskId"] }, { "key": "control-plane:TaskRunAttempt.queue", "onDelete": "Cascade", - "fkColumns": [ - "queueId" - ] + "fkColumns": ["queueId"] }, { "key": "control-plane:TaskRunAttempt.runtimeEnvironment", "onDelete": "Cascade", - "fkColumns": [ - "runtimeEnvironmentId" - ] + "fkColumns": ["runtimeEnvironmentId"] }, { "key": "control-plane:TaskRunCheckpoint.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:TaskRunCheckpoint.runtimeEnvironment", "onDelete": "Cascade", - "fkColumns": [ - "runtimeEnvironmentId" - ] + "fkColumns": ["runtimeEnvironmentId"] }, { "key": "control-plane:TaskRunNumberCounter.environment", "onDelete": "Cascade", - "fkColumns": [ - "environmentId" - ] + "fkColumns": ["environmentId"] }, { "key": "control-plane:TaskRunWaitpoint.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:Waitpoint.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:WaitpointTag.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:WorkerDeployment.triggeredBy", "onDelete": "SetNull", - "fkColumns": [ - "triggeredById" - ] + "fkColumns": ["triggeredById"] }, { "key": "control-plane:WorkerDeploymentPromotion.deployment", "onDelete": "Cascade", - "fkColumns": [ - "deploymentId" - ] + "fkColumns": ["deploymentId"] }, { "key": "control-plane:WorkerInstance.deployment", "onDelete": "SetNull", - "fkColumns": [ - "deploymentId" - ] + "fkColumns": ["deploymentId"] }, { "key": "control-plane:WorkerInstance.environment", "onDelete": "Cascade", - "fkColumns": [ - "environmentId" - ] + "fkColumns": ["environmentId"] }, { "key": "control-plane:WorkerInstance.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:WorkerInstance.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "control-plane:WorkerInstanceGroup.organization", "onDelete": "Cascade", - "fkColumns": [ - "organizationId" - ] + "fkColumns": ["organizationId"] }, { "key": "control-plane:WorkerInstanceGroup.project", "onDelete": "Cascade", - "fkColumns": [ - "projectId" - ] + "fkColumns": ["projectId"] }, { "key": "run-ops:CheckpointRestoreEvent.attempt", "onDelete": "Cascade", - "fkColumns": [ - "attemptId" - ] + "fkColumns": ["attemptId"] }, { "key": "run-ops:TaskRun.batch", "onDelete": "SetNull", - "fkColumns": [ - "batchId" - ] + "fkColumns": ["batchId"] }, { "key": "run-ops:TaskRun.parentTaskRunAttempt", "onDelete": "SetNull", - "fkColumns": [ - "parentTaskRunAttemptId" - ] + "fkColumns": ["parentTaskRunAttemptId"] }, { "key": "run-ops:TaskRun.rootTaskRun", "onDelete": "SetNull", - "fkColumns": [ - "rootTaskRunId" - ] + "fkColumns": ["rootTaskRunId"] } ] } diff --git a/apps/webapp/scripts/fkCascadeIndexGuard.ts b/apps/webapp/scripts/fkCascadeIndexGuard.ts index fe8ce4f659..7d6986e409 100644 --- a/apps/webapp/scripts/fkCascadeIndexGuard.ts +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -19,6 +19,10 @@ * 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. + * * 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 @@ -67,6 +71,36 @@ type Violation = { onDelete: string; }; +function fingerprint(key: string, onDelete: string, fkColumns: string[]): string { + return `${key}::${onDelete}::${fkColumns.join(",")}`; +} + +function stripLineComment(line: string): string { + const i = line.indexOf("//"); + return i === -1 ? line : line.slice(0, i); +} + +function toLogicalLines(body: string): string[] { + const out: string[] = []; + let buf = ""; + let depth = 0; + for (const raw of body.split("\n")) { + const line = stripLineComment(raw).trim(); + if (line === "") continue; + buf = buf === "" ? line : `${buf} ${line}`; + for (const ch of line) { + 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; @@ -89,12 +123,10 @@ function scanSchema(label: string, file: string): Violation[] { let mm: RegExpExecArray | null; while ((mm = modelRe.exec(text))) { const model = mm[1]; - const body = mm[2]; - const lines = body.split("\n"); + const lines = toLogicalLines(mm[2]); const leadingIndexed = new Set(); - for (const raw of lines) { - const line = raw.trim(); + for (const line of lines) { const block = /^@@(index|unique|id)\(\s*\[([^\]]*)\]/.exec(line); if (block) { const lead = leadingColumn(block[2]); @@ -107,8 +139,7 @@ function scanSchema(label: string, file: string): Violation[] { } } - for (const raw of lines) { - const line = raw.trim(); + for (const line of lines) { if (!line.includes("@relation(")) continue; const onDelete = /onDelete:\s*(Cascade|SetNull)/.exec(line); if (!onDelete) continue; @@ -134,6 +165,52 @@ function scanSchema(label: string, file: string): Violation[] { 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"; +} + +function loadBaselineFingerprints(): Set { + 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 fps = new Set(); + for (const v of violations) { + const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown }; + if ( + typeof entry.key !== "string" || + typeof entry.onDelete !== "string" || + !Array.isArray(entry.fkColumns) || + !entry.fkColumns.every((c) => typeof c === "string") + ) { + console.error(`baseline has a malformed entry: ${JSON.stringify(v)}`); + process.exit(2); + } + fps.add(fingerprint(entry.key, entry.onDelete, entry.fkColumns as string[])); + } + return fps; +} + function main() { const check = process.argv.includes("--check"); @@ -148,18 +225,11 @@ function main() { all.sort((a, b) => a.key.localeCompare(b.key)); if (!check) { - const baseline = { - _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.", - violations: all.map((v) => ({ - key: v.key, - onDelete: v.onDelete, - fkColumns: v.fkColumns, - })), - }; - fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + 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; @@ -169,14 +239,11 @@ function main() { console.error(`baseline missing: ${BASELINE_PATH}. Run without --check to generate it.`); process.exit(2); } - const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")) as { - violations: { key: string }[]; - }; - const baselined = new Set(baseline.violations.map((v) => v.key)); - const fresh = all.filter((v) => !baselined.has(v.key)); + const baselined = loadBaselineFingerprints(); + const fresh = all.filter((v) => !baselined.has(fingerprint(v.key, v.onDelete, v.fkColumns))); if (fresh.length === 0) { - console.log(`fk-cascade-index guard: OK (${all.length} baselined, 0 new).`); + console.log(`fk-cascade-index guard: OK (${baselined.size} baselined, 0 new).`); return; } From 02a93d4ca49eb250255f85c5daa41c54a8883fea Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Aug 2026 13:21:49 +0100 Subject: [PATCH 3/4] fix(fk-cascade-guard): quote-aware schema parsing + stricter baseline validation Addresses CodeRabbit follow-up on #4618: - Mask string literals before counting delimiters and stripping // comments, so a quoted delimiter (e.g. a relation name containing a paren) or a quoted // (e.g. a URL @default) no longer splits a multiline relation and silently skips its cascade. - Baseline validation now requires onDelete to be Cascade or SetNull and fkColumns to be non-empty, rejecting a hand-edited baseline with junk values (exit 2). --- apps/webapp/scripts/fkCascadeIndexGuard.ts | 41 +++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/webapp/scripts/fkCascadeIndexGuard.ts b/apps/webapp/scripts/fkCascadeIndexGuard.ts index 7d6986e409..5a0aef1dae 100644 --- a/apps/webapp/scripts/fkCascadeIndexGuard.ts +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -75,9 +75,32 @@ function fingerprint(key: string, onDelete: string, fkColumns: string[]): string return `${key}::${onDelete}::${fkColumns.join(",")}`; } -function stripLineComment(line: string): string { - const i = line.indexOf("//"); - return i === -1 ? line : line.slice(0, i); +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[] { @@ -85,10 +108,11 @@ function toLogicalLines(body: string): string[] { let buf = ""; let depth = 0; for (const raw of body.split("\n")) { - const line = stripLineComment(raw).trim(); - if (line === "") continue; - buf = buf === "" ? line : `${buf} ${line}`; - for (const ch of line) { + 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); } @@ -199,8 +223,9 @@ function loadBaselineFingerprints(): Set { const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown }; if ( typeof entry.key !== "string" || - typeof entry.onDelete !== "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)}`); From fe128502eb01b043adf7d0a10780aadd3f4242f2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 14 Aug 2026 13:39:20 +0100 Subject: [PATCH 4/4] 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. --- apps/webapp/scripts/fkCascadeIndexGuard.ts | 78 ++++++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/apps/webapp/scripts/fkCascadeIndexGuard.ts b/apps/webapp/scripts/fkCascadeIndexGuard.ts index 5a0aef1dae..558f0439c9 100644 --- a/apps/webapp/scripts/fkCascadeIndexGuard.ts +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -21,7 +21,10 @@ * * 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. + * 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 @@ -205,7 +208,9 @@ function serializeBaseline(comment: string, violations: Violation[]): string { return lines.join("\n") + "\n"; } -function loadBaselineFingerprints(): Set { +type BaselineEntry = { key: string; onDelete: string; fkColumns: string[] }; + +function loadBaseline(): BaselineEntry[] { let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); @@ -218,7 +223,7 @@ function loadBaselineFingerprints(): Set { console.error(`baseline is missing a "violations" array: ${BASELINE_PATH}.`); process.exit(2); } - const fps = new Set(); + const entries: BaselineEntry[] = []; for (const v of violations) { const entry = v as { key?: unknown; onDelete?: unknown; fkColumns?: unknown }; if ( @@ -231,9 +236,13 @@ function loadBaselineFingerprints(): Set { console.error(`baseline has a malformed entry: ${JSON.stringify(v)}`); process.exit(2); } - fps.add(fingerprint(entry.key, entry.onDelete, entry.fkColumns as string[])); + entries.push({ + key: entry.key, + onDelete: entry.onDelete, + fkColumns: entry.fkColumns as string[], + }); } - return fps; + return entries; } function main() { @@ -264,30 +273,55 @@ function main() { console.error(`baseline missing: ${BASELINE_PATH}. Run without --check to generate it.`); process.exit(2); } - const baselined = loadBaselineFingerprints(); - const fresh = all.filter((v) => !baselined.has(fingerprint(v.key, v.onDelete, v.fkColumns))); + 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))); - if (fresh.length === 0) { - console.log(`fk-cascade-index guard: OK (${baselined.size} baselined, 0 new).`); + 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; } - 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) { + if (fresh.length > 0) { console.error( - ` ${v.schema}: ${v.model}.${v.relationField} ` + - `(onDelete: ${v.onDelete}, fk: [${v.fkColumns.join(", ")}])` + `\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` ); } - 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); }