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..cd2592f6d8 --- /dev/null +++ b/apps/webapp/scripts/fk-cascade-index-baseline.json @@ -0,0 +1,365 @@ +{ + "_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..558f0439c9 --- /dev/null +++ b/apps/webapp/scripts/fkCascadeIndexGuard.ts @@ -0,0 +1,328 @@ +/** + * 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(); + 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();