ci(webapp): wire the run-ops legacy guard into CI and add oxlint residency fences (#4279)
## What - Runs `apps/webapp/scripts/runOpsLegacyGuard.ts --check` as its own PR job (`runops-guard`), so code that reaches a run-graph table through the control-plane Prisma client instead of the RunStore fails the build. - Adds a `trigger-runops` oxlint plugin with two fast, in-editor rules scoped to `apps/webapp/app`: one for direct `prisma.taskRun`-style access, one for a control-plane client wired into a read-through slot. These are the cheap fence; the guard is the type-aware gate. - Fixes `CancelTaskRunService.callV1`: historical V1 runs are legacy-resident, so its two finalize writes now go through `runOpsLegacyPrisma` instead of the control-plane client (they'd miss the row once legacy is a separate database). - Regenerates the guard baseline, which had drifted stale (it referenced files deleted in an earlier PR). ## Why The guard existed but ran nowhere, so its baseline rotted and a real residency gap (the V1 cancel writes) sat undetected. Wiring it into CI turns it into a ratchet against new control-plane run-graph access. ## Verification Local, against a clean regen: `oxfmt --check`, `oxlint .`, `guard --check`, and `typecheck --filter webapp` all pass. Remaining baseline entries are 4 batch-results router reads through type-opaque `as PrismaReplicaClient` casts (correct at runtime, accepted) + 2 sanctioned legacy annotations.
This commit is contained in:
@@ -56,6 +56,7 @@ jobs:
|
||||
- '.github/workflows/pr_checks.yml'
|
||||
- '.github/workflows/unit-tests-webapp.yml'
|
||||
- '.github/workflows/e2e-webapp.yml'
|
||||
- '.github/workflows/runops-guard.yml'
|
||||
- '.configs/**'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
@@ -111,6 +112,11 @@ jobs:
|
||||
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
|
||||
runops-guard:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.webapp == 'true'
|
||||
uses: ./.github/workflows/runops-guard.yml
|
||||
|
||||
webapp:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.webapp == 'true'
|
||||
@@ -161,6 +167,7 @@ jobs:
|
||||
- changes
|
||||
- code-quality
|
||||
- typecheck
|
||||
- runops-guard
|
||||
- webapp
|
||||
- e2e-webapp
|
||||
- packages
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: "🛡️ Run-ops Legacy Guard"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
runops-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: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🛡️ Run-ops legacy guard
|
||||
run: pnpm --filter webapp run guard:runops-legacy -- --check
|
||||
+21
-2
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "import", "react"],
|
||||
"jsPlugins": ["./oxlint-plugins/no-thrown-unawaited-redirect.mjs"],
|
||||
"jsPlugins": [
|
||||
"./oxlint-plugins/no-thrown-unawaited-redirect.mjs",
|
||||
"./oxlint-plugins/runops-residency.mjs"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
@@ -34,5 +37,21 @@
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"trigger/no-thrown-unawaited-redirect": "error"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"],
|
||||
"rules": {
|
||||
"trigger-runops/no-control-plane-run-graph-access": "error",
|
||||
"trigger-runops/no-control-plane-in-runops-slot": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"],
|
||||
"rules": {
|
||||
"trigger-runops/no-control-plane-run-graph-access": "off",
|
||||
"trigger-runops/no-control-plane-in-runops-slot": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,46 +68,52 @@
|
||||
"WaitpointTag.project"
|
||||
],
|
||||
"totals": {
|
||||
"violations": 0,
|
||||
"detectorI": 0,
|
||||
"violations": 4,
|
||||
"detectorI": 4,
|
||||
"detectorII": 0,
|
||||
"detectorIII": 0,
|
||||
"write": 0,
|
||||
"read": 0,
|
||||
"files": 0,
|
||||
"legacyAnnotations": 5
|
||||
"read": 4,
|
||||
"files": 1,
|
||||
"legacyAnnotations": 0
|
||||
},
|
||||
"violations": [],
|
||||
"legacyAnnotations": [
|
||||
"violations": [
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 302,
|
||||
"reason": "OOM machine bump on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
|
||||
"line": 88,
|
||||
"model": "BatchTaskRun",
|
||||
"delegate": "batchTaskRun",
|
||||
"callKind": "read",
|
||||
"detector": "i",
|
||||
"snippet": "const batchRun = await this._replica.batchTaskRun.findFirst({"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 360,
|
||||
"reason": "error on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
|
||||
"line": 149,
|
||||
"model": "BatchTaskRun",
|
||||
"delegate": "batchTaskRun",
|
||||
"callKind": "read",
|
||||
"detector": "i",
|
||||
"snippet": "client.batchTaskRun.findFirst({"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/completeAttempt.server.ts",
|
||||
"line": 604,
|
||||
"reason": "RETRYING status on attempt-owning V1/cuid run",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
|
||||
"line": 183,
|
||||
"model": "TaskRun",
|
||||
"delegate": "taskRun",
|
||||
"callKind": "read",
|
||||
"detector": "i",
|
||||
"snippet": "const newRows = (await newClient.taskRun.findMany({"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/createTaskRunAttempt.server.ts",
|
||||
"line": 168,
|
||||
"reason": "run-bump inside legacy attempt-create tx (V1-only path)",
|
||||
"receiver": "tx"
|
||||
},
|
||||
{
|
||||
"file": "apps/webapp/app/v3/services/executeTasksWaitingForDeploy.ts",
|
||||
"line": 92,
|
||||
"reason": "post ownerEngine()!==\"NEW\" filter",
|
||||
"receiver": "runOpsLegacyPrisma"
|
||||
"file": "apps/webapp/app/presenters/v3/ApiBatchResultsPresenter.server.ts",
|
||||
"line": 195,
|
||||
"model": "TaskRun",
|
||||
"delegate": "taskRun",
|
||||
"callKind": "read",
|
||||
"detector": "i",
|
||||
"snippet": "const legacyRows = (await legacyReplica.taskRun.findMany({"
|
||||
}
|
||||
]
|
||||
],
|
||||
"legacyAnnotations": []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RunEngineVersion, type TaskRun } from "@trigger.dev/database";
|
||||
import { runOpsLegacyPrismaClient } from "~/db.server";
|
||||
import { engine } from "../runEngine.server";
|
||||
import { isCancellableRunStatus } from "../taskStatus";
|
||||
import { BaseService } from "./baseService.server";
|
||||
@@ -43,7 +44,7 @@ export class CancelTaskRunService extends BaseService {
|
||||
// DB row directly. Never throw here: the cancel route returns 500 on any throw.
|
||||
if (!isCancellableRunStatus(taskRun.status)) {
|
||||
if (options?.bulkActionId) {
|
||||
await this._prisma.taskRun.update({
|
||||
await runOpsLegacyPrismaClient.taskRun.update({
|
||||
where: { id: taskRun.id },
|
||||
data: { bulkActionGroupIds: { push: options.bulkActionId } },
|
||||
});
|
||||
@@ -51,7 +52,7 @@ export class CancelTaskRunService extends BaseService {
|
||||
return { id: taskRun.id, alreadyFinished: true };
|
||||
}
|
||||
|
||||
await this._prisma.taskRun.update({
|
||||
await runOpsLegacyPrismaClient.taskRun.update({
|
||||
where: { id: taskRun.id },
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
|
||||
@@ -45,6 +45,41 @@ const RUNOPS_SCHEMA = path.join(
|
||||
);
|
||||
const BASELINE_PATH = path.join(WEBAPP_DIR, "app", "v3", "runOpsMigration", "track1-baseline.json");
|
||||
|
||||
const CP_PACKAGE_DIR = path.dirname(path.dirname(CP_SCHEMA));
|
||||
const RUNOPS_PACKAGE_DIR = path.dirname(path.dirname(RUNOPS_SCHEMA));
|
||||
|
||||
/** Absolute path of the generated Prisma client for a schema, from its generator `output = "..."`. */
|
||||
function generatedClientDir(schemaFile: string): string {
|
||||
const m = /^\s*output\s*=\s*"([^"]+)"/m.exec(fs.readFileSync(schemaFile, "utf8"));
|
||||
if (!m) {
|
||||
console.error(`No generator output path found in ${schemaFile}`);
|
||||
process.exit(2);
|
||||
}
|
||||
return path.resolve(path.dirname(schemaFile), m[1]);
|
||||
}
|
||||
|
||||
const CP_GENERATED_DIR = generatedClientDir(CP_SCHEMA);
|
||||
const RUNOPS_GENERATED_DIR = generatedClientDir(RUNOPS_SCHEMA);
|
||||
|
||||
function realpathIfExists(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Classification roots, realpath'd to match the checker's realpath'd file names.
|
||||
const RUNOPS_DECL_DIRS = [RUNOPS_GENERATED_DIR, RUNOPS_PACKAGE_DIR].map(realpathIfExists);
|
||||
const CP_DECL_DIRS = [CP_GENERATED_DIR, CP_PACKAGE_DIR].map(realpathIfExists);
|
||||
|
||||
// Both client packages are force-resolved to SOURCE (src/ + generated client) when building the
|
||||
// program, so classification never depends on a built/fresh `dist/` in the running environment.
|
||||
const FORCED_TYPE_RESOLUTIONS = new Map<string, string>([
|
||||
["@trigger.dev/database", path.join(CP_PACKAGE_DIR, "src", "index.ts")],
|
||||
["@internal/run-ops-database", path.join(RUNOPS_PACKAGE_DIR, "src", "index.ts")],
|
||||
]);
|
||||
|
||||
// Files excluded from the sweep. V1-only files come from .claude/rules/legacy-v3-code.md.
|
||||
const V1_FILES = new Set(
|
||||
[
|
||||
@@ -254,7 +289,41 @@ function buildProgram(): ts.Program {
|
||||
};
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(TSCONFIG_PATH, undefined, host);
|
||||
if (!parsed) throw new Error(`Failed to parse ${TSCONFIG_PATH}`);
|
||||
return ts.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
|
||||
// Resolve workspace packages from source (tsconfig.check.json blanks this to target built dists,
|
||||
// which may not exist here), and pin the two client packages to their src entrypoints —
|
||||
// @trigger.dev/database has no exports map, so the condition alone cannot redirect it.
|
||||
const options: ts.CompilerOptions = {
|
||||
...parsed.options,
|
||||
customConditions: ["@triggerdotdev/source"],
|
||||
};
|
||||
const compilerHost = ts.createCompilerHost(options);
|
||||
const resolutionCache = ts.createModuleResolutionCache(
|
||||
compilerHost.getCurrentDirectory(),
|
||||
(f) => compilerHost.getCanonicalFileName(f),
|
||||
options
|
||||
);
|
||||
compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirected, opts) =>
|
||||
moduleLiterals.map((lit) => {
|
||||
const forced = FORCED_TYPE_RESOLUTIONS.get(lit.text);
|
||||
if (forced) {
|
||||
return {
|
||||
resolvedModule: {
|
||||
resolvedFileName: forced,
|
||||
extension: ts.Extension.Ts,
|
||||
isExternalLibraryImport: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return ts.resolveModuleName(
|
||||
lit.text,
|
||||
containingFile,
|
||||
opts,
|
||||
compilerHost,
|
||||
resolutionCache,
|
||||
redirected
|
||||
);
|
||||
});
|
||||
return ts.createProgram({ rootNames: parsed.fileNames, options, host: compilerHost });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -263,7 +332,17 @@ function buildProgram(): ts.Program {
|
||||
|
||||
type ClientKind = "cp" | "runops" | "other";
|
||||
|
||||
function underDir(file: string, dir: string): boolean {
|
||||
const rel = path.relative(dir, file);
|
||||
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
// Absolute-dir match against the two packages first (environment-independent); substring match is
|
||||
// only a fallback for non-workspace layouts (pnpm store paths) and the virtual self-test files.
|
||||
function declFileKind(fileName: string): ClientKind | undefined {
|
||||
const abs = path.resolve(fileName);
|
||||
if (RUNOPS_DECL_DIRS.some((d) => underDir(abs, d))) return "runops";
|
||||
if (CP_DECL_DIRS.some((d) => underDir(abs, d))) return "cp";
|
||||
const f = fileName.split(path.sep).join("/");
|
||||
if (f.includes("run-ops-database")) return "runops";
|
||||
if (f.includes("internal-packages/database")) return "cp";
|
||||
@@ -629,9 +708,65 @@ function collectCrossSeamHits(
|
||||
// Scan
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Known-kind receivers in db.server.ts. If either fails to classify, the checker cannot see the
|
||||
// client types (e.g. generated clients missing) and every verdict would be a silent false-clean,
|
||||
// so the guard refuses to report instead of rubber-stamping.
|
||||
const CLASSIFICATION_ANCHORS: Array<{ name: string; expected: ClientKind }> = [
|
||||
{ name: "runOpsLegacyPrisma", expected: "cp" },
|
||||
{ name: "runOpsNewPrismaClient", expected: "runops" },
|
||||
];
|
||||
|
||||
function assertClassificationAnchors(program: ts.Program, checker: ts.TypeChecker): void {
|
||||
const anchorFile = path.join(WEBAPP_DIR, "app", "db.server.ts");
|
||||
const sf = program.getSourceFile(anchorFile);
|
||||
const problems: string[] = [];
|
||||
if (!sf) {
|
||||
problems.push(`${repoRel(anchorFile)} is not part of the program`);
|
||||
} else {
|
||||
for (const anchor of CLASSIFICATION_ANCHORS) {
|
||||
let ident: ts.Identifier | undefined;
|
||||
const find = (node: ts.Node): void => {
|
||||
if (ident) return;
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.name.text === anchor.name
|
||||
) {
|
||||
ident = node.name;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(node, find);
|
||||
};
|
||||
find(sf);
|
||||
if (!ident) {
|
||||
problems.push(`anchor "${anchor.name}" not found in ${repoRel(anchorFile)}`);
|
||||
continue;
|
||||
}
|
||||
const receiverType = checker.getTypeAtLocation(ident);
|
||||
const delegateSym = receiverType.getProperty("taskRun");
|
||||
const got = delegateSym
|
||||
? classifyType(checker, checker.getTypeOfSymbolAtLocation(delegateSym, ident))
|
||||
: "unresolved";
|
||||
if (got !== anchor.expected) {
|
||||
problems.push(`${anchor.name}.taskRun classified "${got}", expected "${anchor.expected}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (problems.length) {
|
||||
console.error(
|
||||
`[runops-guard] CLASSIFICATION ANCHORS FAILED — the guard cannot distinguish the ` +
|
||||
`control-plane and run-ops clients in this environment, so its verdicts would be ` +
|
||||
`meaningless:\n ${problems.join("\n ")}\n` +
|
||||
`Ensure the generated Prisma clients exist (pnpm run generate) and retry.`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function scan(): ScanResult {
|
||||
const program = buildProgram();
|
||||
const checker = program.getTypeChecker();
|
||||
assertClassificationAnchors(program, checker);
|
||||
return scanProgram(program, checker, isInScope);
|
||||
}
|
||||
|
||||
@@ -1134,6 +1269,15 @@ function main(): void {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
for (const dir of [CP_GENERATED_DIR, RUNOPS_GENERATED_DIR]) {
|
||||
if (!fs.existsSync(path.join(dir, "index.d.ts"))) {
|
||||
console.error(
|
||||
`[runops-guard] generated Prisma client missing at ${repoRel(dir)} — run: pnpm run generate`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[runops-guard] repo root: ${REPO_ROOT}`);
|
||||
console.error(
|
||||
`[runops-guard] run-graph delegates: ${Array.from(RUN_GRAPH_DELEGATES).join(", ")}`
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* oxlint plugin: trigger-runops — fast in-editor fences for the run-ops DB split.
|
||||
* Name-based ports of two detectors from the authoritative type-aware guard
|
||||
* (apps/webapp/scripts/runOpsLegacyGuard.ts, run as `--check` in CI). Scoped to
|
||||
* apps/webapp/app via .oxlintrc.json overrides, matching the guard's scope.
|
||||
*/
|
||||
|
||||
// The 16 run-graph delegates (camelCased run-ops models). Authoritative source is
|
||||
// internal-packages/run-ops-database/prisma/schema.prisma, which the tsx guard
|
||||
// parses; kept as a literal here so a missing schema can never disable linting.
|
||||
const RUN_GRAPH_DELEGATES = new Set([
|
||||
"taskRun",
|
||||
"taskRunExecutionSnapshot",
|
||||
"taskRunCheckpoint",
|
||||
"waitpoint",
|
||||
"taskRunWaitpoint",
|
||||
"waitpointRunConnection",
|
||||
"completedWaitpoint",
|
||||
"waitpointTag",
|
||||
"taskRunTag",
|
||||
"taskRunDependency",
|
||||
"taskRunAttempt",
|
||||
"batchTaskRun",
|
||||
"batchTaskRunItem",
|
||||
"batchTaskRunError",
|
||||
"checkpoint",
|
||||
"checkpointRestoreEvent",
|
||||
]);
|
||||
|
||||
// Global control-plane client exports (from ~/db.server).
|
||||
const CONTROL_PLANE_GLOBALS = new Set(["prisma", "$replica"]);
|
||||
|
||||
// Read-through config slots that MUST carry a run-ops client. Kept in sync with
|
||||
// RUN_OPS_READTHROUGH_SLOTS in scripts/runOpsLegacyGuard.ts. `controlPlaneReplica`
|
||||
// is intentionally absent — it is meant to be control-plane.
|
||||
const RUN_OPS_READTHROUGH_SLOTS = new Set([
|
||||
"newClient",
|
||||
"newReplica",
|
||||
"runOpsNew",
|
||||
"legacyReplica",
|
||||
"runOpsLegacyReplica",
|
||||
]);
|
||||
const CONTROL_PLANE_CLIENT_IDENTIFIERS = new Set(["$replica", "prisma"]);
|
||||
const CONTROL_PLANE_THIS_FIELDS = new Set(["_replica", "_prisma"]);
|
||||
|
||||
// Parentheses are not nodes in oxlint's ESTree, so only type-only wrappers unwrap.
|
||||
function unwrap(node) {
|
||||
let current = node;
|
||||
while (
|
||||
current &&
|
||||
(current.type === "TSAsExpression" ||
|
||||
current.type === "TSSatisfiesExpression" ||
|
||||
current.type === "TSNonNullExpression")
|
||||
) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function valueIsControlPlaneClient(node) {
|
||||
const expr = unwrap(node);
|
||||
if (!expr) return false;
|
||||
if (expr.type === "Identifier") return CONTROL_PLANE_CLIENT_IDENTIFIERS.has(expr.name);
|
||||
if (
|
||||
expr.type === "MemberExpression" &&
|
||||
!expr.computed &&
|
||||
expr.object.type === "ThisExpression" &&
|
||||
expr.property.type === "Identifier"
|
||||
) {
|
||||
return CONTROL_PLANE_THIS_FIELDS.has(expr.property.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function propertyKeyName(property) {
|
||||
const key = property.key;
|
||||
if (!key) return undefined;
|
||||
if (key.type === "Identifier" && !property.computed) return key.name;
|
||||
if (key.type === "Literal" && typeof key.value === "string") return key.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noControlPlaneRunGraphAccess = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow reaching a run-graph table through the global control-plane Prisma client; route through the RunStore.",
|
||||
},
|
||||
messages: {
|
||||
leak: 'Run-graph table "{{delegate}}" is reached through the control-plane client "{{client}}". Once run-ops is a separate database this reads/writes the wrong DB — route through the RunStore instead.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
if (node.computed || node.property.type !== "Identifier") return;
|
||||
if (!RUN_GRAPH_DELEGATES.has(node.property.name)) return;
|
||||
if (node.object.type !== "Identifier" || !CONTROL_PLANE_GLOBALS.has(node.object.name)) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: "leak",
|
||||
data: { delegate: node.property.name, client: node.object.name },
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noControlPlaneInRunOpsSlot = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow assigning a control-plane Prisma client into a run-ops read-through config slot (routes run-ops reads at the wrong database).",
|
||||
},
|
||||
messages: {
|
||||
misroute:
|
||||
'Run-ops read-through slot "{{slot}}" is assigned the control-plane client "{{client}}". This routes run-ops reads at the control-plane database. Assign the run-ops client instead.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Property(node) {
|
||||
const slot = propertyKeyName(node);
|
||||
if (!slot || !RUN_OPS_READTHROUGH_SLOTS.has(slot)) return;
|
||||
if (node.shorthand) return; // `{ legacyReplica }` binds a same-named local, not a cp export.
|
||||
if (!valueIsControlPlaneClient(node.value)) return;
|
||||
const client =
|
||||
node.value.type === "Identifier"
|
||||
? node.value.name
|
||||
: context.sourceCode.getText(unwrap(node.value));
|
||||
context.report({ node: node.value, messageId: "misroute", data: { slot, client } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import("eslint").ESLint.Plugin} */
|
||||
const plugin = {
|
||||
// Distinct namespace: oxlint keys plugins by meta.name, so this cannot reuse
|
||||
// "trigger" without clobbering no-thrown-unawaited-redirect.
|
||||
meta: { name: "trigger-runops" },
|
||||
rules: {
|
||||
"no-control-plane-run-graph-access": noControlPlaneRunGraphAccess,
|
||||
"no-control-plane-in-runops-slot": noControlPlaneInRunOpsSlot,
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
Reference in New Issue
Block a user