Skip to content

refactor(sql-runtime): move budgets plugin with AST rewrite#250

Merged
wmadden merged 13 commits intomainfrom
tml-2099-move-budgets-plugin-from-framework-domain-to-sql-domain-next-to-lints
Mar 26, 2026
Merged

refactor(sql-runtime): move budgets plugin with AST rewrite#250
wmadden merged 13 commits intomainfrom
tml-2099-move-budgets-plugin-from-framework-domain-to-sql-domain-next-to-lints

Conversation

@wmadden
Copy link
Contributor

@wmadden wmadden commented Mar 25, 2026

closes TML-2099

Key snippet (Before / After)

// BEFORE — budgets plugin dispatched via SQL string parsing (Framework domain)
const sqlUpper = plan.sql.trimStart().toUpperCase();
const isSelect = sqlUpper.startsWith('SELECT');
// ...
const limit = plan.meta.annotations?.['limit'];
// AFTER — budgets plugin dispatches via AST type check (SQL domain)
if (plan.ast instanceof SelectAst) {
  return evaluateSelectAst(plan, plan.ast, ctx);
}
if (plan.ast) {
  return; // non-SELECT AST — no row budget applicable
}
return evaluateWithHeuristics(plan, ctx); // raw SQL fallback

Intent

Move the budgets runtime plugin from the Framework domain to the SQL domain (next to lints) so it can inspect QueryAst structurally, replacing fragile SQL string parsing and annotation heuristics with AST-based SELECT detection, LIMIT detection, and row estimation. The public API (import { budgets } from '@prisma-next/sql-runtime') is unchanged.

Change map

The story

  1. Move the plugin across the layering boundary: budgets.ts moves from packages/1-framework/4-runtime-executor/src/plugins/ to packages/2-sql/5-runtime/src/plugins/, placing it in the SQL domain where it can import AST types. Imports are updated: relative ./types becomes @prisma-next/runtime-executor (matching how lints.ts already imports plugin types). Exports are rewired so @prisma-next/sql-runtime sources budgets locally, and @prisma-next/runtime-executor no longer exports it.

  2. Replace heuristics with AST-based dispatch: The beforeExecute method now checks plan.ast instanceof SelectAst first. When AST is present, SELECT detection is structural (type check, not string parsing), LIMIT detection reads ast.limit directly (not plan.meta.annotations?.['limit']), and aggregate-without-GROUP-BY queries get an estimate of 1 row. When AST is absent (raw SQL), the original heuristics are preserved as a fallback.

  3. Extract duplication into helpers: The throw-or-warn pattern, previously duplicated ~6 times inline, is extracted into emitBudgetViolation. Row estimation is split into two pure functions: estimateRowsFromAst and estimateRowsFromHeuristics. The inner dispatch functions evaluateSelectAst and evaluateWithHeuristics close over plugin config.

Behavior changes & evidence

Compatibility / migration / risk

  • Consumer-facing import unchanged: import { budgets } from '@prisma-next/sql-runtime' continues to work. The BudgetsOptions type and budgets() function signature are unchanged.
  • Breaking for direct runtime-executor importers: Anyone importing budgets directly from @prisma-next/runtime-executor will get a compile-time error. This is intentional — the spec requires removing the export.
  • No performance risk: AST is already in memory; no new I/O or EXPLAIN queries.

Non-goals / intentionally out of scope

  • Refactoring evaluateRawGuardrails (stays in Framework domain)
  • Merging budgets and lints into a single plugin
  • Adding new budget types (e.g. sql-size from ADR 023)
  • TML-2096 (kind-based dispatch to replace instanceof)

Summary by CodeRabbit

  • Breaking Changes

    • budgets and BudgetsOptions are now provided by the SQL runtime; update imports accordingly.
  • New Features / Improvements

    • New budgets runtime plugin enforcing row-count and latency limits with AST-driven, heuristic, and EXPLAIN fallbacks.
    • Minor CLI help formatting refinement.
  • Documentation

    • READMEs updated to reflect export changes and AST-first estimation.
  • Tests

    • Comprehensive tests added for row/latency budgeting and all estimation paths.

wmadden added 6 commits March 25, 2026 14:54
The budgets plugin needs AST access to replace fragile string-parsing
heuristics. Moving it from runtime-executor (framework) to sql-runtime
(SQL domain) — next to the lints plugin — enables importing SQL AST
types. Re-exports are rewired so the public API is unchanged.

Unit tests for the current heuristic behavior are added alongside the
move to guard against regressions during the AST rewrite.
Replace string-parsing heuristics with AST-based checks:
- SELECT detection via instanceof SelectAst (not sql.startsWith)
- LIMIT detection via ast.limit (not plan.meta.annotations)
- Aggregate without GROUP BY estimates 1 row

The raw SQL fallback path (string parsing, annotations, EXPLAIN)
is preserved for plans without an AST.
runtime-executor README no longer lists budgets as an export.
sql-runtime README describes budgets as a local AST-first plugin.
PlanRefs.tables is readonly string[], so the parameter type must
accept readonly arrays.
… edge case

Lock in the 'ast' source value in budget error details and add a test
for unbounded SelectAst without table refs (code review F01 + F02).
@coderabbitai
Copy link

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Moves the budgets plugin and its type from the framework runtime-executor into the SQL runtime, implements a new AST-first SQL-domain budgets plugin with heuristic and optional EXPLAIN fallbacks, updates exports and READMEs, tweaks a CLI formatter line, and adds comprehensive Vitest tests for budgets behavior.

Changes

Cohort / File(s) Summary
Help formatter cleanup
packages/1-framework/3-tooling/cli/src/utils/formatters/help.ts
Simplified "Read more" padding computation to a single template literal; no behavior change.
Framework runtime-executor exports & docs
packages/1-framework/4-runtime-executor/src/exports/index.ts, packages/1-framework/4-runtime-executor/README.md
Removed budgets and BudgetsOptions from framework runtime-executor exports; README example updated to omit budgets.
SQL runtime exports & README
packages/2-sql/5-runtime/src/exports/index.ts, packages/2-sql/5-runtime/README.md
Re-pointed budgets and BudgetsOptions exports to the SQL runtime package; README updated to describe AST-first SQL-domain budget plugin.
Budgets plugin implementation
packages/2-sql/5-runtime/src/plugins/budgets.ts
Added BudgetsOptions and budgets plugin: AST-first SelectAst estimation, heuristic metadata fallback, optional EXPLAIN-based estimates, observed-row counting, latency checks, and unified violation emission with severity/strict-mode handling.
Budgets test suite
packages/2-sql/5-runtime/test/budgets.test.ts
Added Vitest suite covering AST-based and heuristic row estimation, EXPLAIN fallback, observed-row enforcement, latency budgeting (warn vs error in permissive/strict modes), aggregates/group-by handling, and edge cases.

Sequence Diagram(s)

sequenceDiagram
  participant Executor
  participant BudgetsPlugin as Budgets Plugin
  participant PlanAst as Plan.ast
  participant Heuristics
  participant Driver as ctx.driver.explain
  participant Context as PluginContext

  Executor->>BudgetsPlugin: beforeExecute(plan, ctx)
  alt plan.ast is SelectAst
    BudgetsPlugin->>PlanAst: inspect SelectAst (tables, limit, aggregates)
    BudgetsPlugin-->>Context: emit BUDGET.ROWS_EXCEEDED (source: 'ast') or continue
  else no AST or non-SelectAst
    BudgetsPlugin->>Heuristics: analyze sql/meta for limits
    alt heuristics indicate bounded
      BudgetsPlugin-->>Context: continue (within budget)
    else heuristics unbounded
      BudgetsPlugin->>Driver: explain(sql, params) [optional fallback]
      alt explain provides estimate
        BudgetsPlugin-->>Context: emit violation (source: 'explain')
      else explain fails
        BudgetsPlugin-->>Context: proceed without row violation
      end
    end
  end
  Executor->>BudgetsPlugin: onRow(row)
  BudgetsPlugin-->>Context: increment observed rows -> emit 'observed' violation if exceeded
  Executor->>BudgetsPlugin: afterExecute(result)
  BudgetsPlugin-->>Context: check latency -> warn or error per severities & mode
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped the budgets from framework to SQL,

AST-first checks then heuristics too,
EXPLAIN peeks when limits are few,
Tests chase rows and time with zeal,
I twitch my nose—this plugin’s real! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: moving the budgets plugin from the Framework domain to the SQL domain with an AST rewrite implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tml-2099-move-budgets-plugin-from-framework-domain-to-sql-domain-next-to-lints

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 25, 2026

Open in StackBlitz

@prisma-next/runtime-executor

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/runtime-executor@250

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@250

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@250

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@250

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@250

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@250

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@250

@prisma-next/contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-ts@250

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@250

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@250

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@250

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@250

@prisma-next/eslint-plugin

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/eslint-plugin@250

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@250

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@250

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@250

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@250

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@250

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@250

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@250

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@250

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@250

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@250

@prisma-next/sql-kysely-lane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-kysely-lane@250

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@250

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@250

@prisma-next/sql-lane-sql-builder-new

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-sql-builder-new@250

@prisma-next/sql-lane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane@250

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@250

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@250

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@250

@prisma-next/core-control-plane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/core-control-plane@250

@prisma-next/core-execution-plane

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/core-execution-plane@250

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@250

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@250

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@250

@prisma-next/plan

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/plan@250

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@250

commit: ab52b18

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/2-sql/5-runtime/test/budgets.test.ts (1)

45-53: Consider removing unnecessary type assertions in createPlan.

The as PlanMeta (line 50) and as ExecutionPlan (line 52) casts might be hiding type mismatches. If the spread produces a valid PlanMeta/ExecutionPlan, the casts are redundant. If not, the helper may need adjustment.

Per coding guidelines, test code should use double casts (as unknown as X) at mock boundaries rather than direct as casts to make the unsafe boundary explicit.

♻️ Suggested adjustment
 function createPlan(overrides: PlanOverrides): ExecutionPlan {
   const { meta: metaOverrides, ...rest } = overrides;
-  return {
+  return {
     sql: 'SELECT 1',
     params: [],
-    meta: { ...baseMeta, ...(metaOverrides ?? {}) } as PlanMeta,
+    meta: { ...baseMeta, ...(metaOverrides ?? {}) },
     ...rest,
-  } as ExecutionPlan;
+  } as unknown as ExecutionPlan;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/test/budgets.test.ts` around lines 45 - 53, The
helper createPlan currently uses unsafe one-step casts "as PlanMeta" and "as
ExecutionPlan"; remove those direct casts and let TypeScript infer types by
ensuring the returned object matches ExecutionPlan (adjust the meta spread to
produce a PlanMeta using baseMeta and metaOverrides) — if the test truly needs
an unsafe mock, replace each single "as X" with an explicit double cast "as
unknown as X" to mark the unsafe boundary; target the createPlan function and
the PlanOverrides/PlanMeta spread (baseMeta, metaOverrides) when making the
change.
packages/2-sql/5-runtime/src/plugins/budgets.ts (1)

261-306: Consider extracting hasAggregateWithoutGroupBy call to avoid duplication.

hasAggregateWithoutGroupBy(ast) is called twice in evaluateSelectAst: once in estimateRowsFromAst (line 131) and again directly (line 267). Since this involves iterating over projections, consider storing the result in a local variable.

♻️ Proposed optimization
 function evaluateSelectAst(
   plan: ExecutionPlan,
   ast: SelectAst,
   ctx: PluginContext<TContract, TAdapter, TDriver>,
 ) {
+  const isAggregateOnly = hasAggregateWithoutGroupBy(ast);
-  const estimated = estimateRowsFromAst(ast, tableRows, defaultTableRows, plan.meta.refs);
-  const isUnbounded = ast.limit === undefined && !hasAggregateWithoutGroupBy(ast);
+  const estimated = isAggregateOnly
+    ? 1
+    : estimateRowsFromAstCore(ast, tableRows, defaultTableRows, plan.meta.refs);
+  const isUnbounded = ast.limit === undefined && !isAggregateOnly;
   const shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 261 - 306,
evaluateSelectAst calls hasAggregateWithoutGroupBy(ast) twice (once locally and
once inside estimateRowsFromAst); compute it once and reuse: create a local
const (e.g., hasAggNoGroup = hasAggregateWithoutGroupBy(ast)), use that in the
isUnbounded check inside evaluateSelectAst, and refactor estimateRowsFromAst to
accept an optional hasAggregateWithoutGroup boolean parameter (update its
signature and all callers) so estimateRowsFromAst can use the precomputed value
instead of recomputing; update calls to estimateRowsFromAst within
evaluateSelectAst to pass hasAggNoGroup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 261-306: evaluateSelectAst calls hasAggregateWithoutGroupBy(ast)
twice (once locally and once inside estimateRowsFromAst); compute it once and
reuse: create a local const (e.g., hasAggNoGroup =
hasAggregateWithoutGroupBy(ast)), use that in the isUnbounded check inside
evaluateSelectAst, and refactor estimateRowsFromAst to accept an optional
hasAggregateWithoutGroup boolean parameter (update its signature and all
callers) so estimateRowsFromAst can use the precomputed value instead of
recomputing; update calls to estimateRowsFromAst within evaluateSelectAst to
pass hasAggNoGroup.

In `@packages/2-sql/5-runtime/test/budgets.test.ts`:
- Around line 45-53: The helper createPlan currently uses unsafe one-step casts
"as PlanMeta" and "as ExecutionPlan"; remove those direct casts and let
TypeScript infer types by ensuring the returned object matches ExecutionPlan
(adjust the meta spread to produce a PlanMeta using baseMeta and metaOverrides)
— if the test truly needs an unsafe mock, replace each single "as X" with an
explicit double cast "as unknown as X" to mark the unsafe boundary; target the
createPlan function and the PlanOverrides/PlanMeta spread (baseMeta,
metaOverrides) when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 29a9001b-37b2-4955-a410-748d2974695d

📥 Commits

Reviewing files that changed from the base of the PR and between 4429f8a and f738ac3.

📒 Files selected for processing (7)
  • packages/1-framework/3-tooling/cli/src/utils/formatters/help.ts
  • packages/1-framework/4-runtime-executor/README.md
  • packages/1-framework/4-runtime-executor/src/exports/index.ts
  • packages/2-sql/5-runtime/README.md
  • packages/2-sql/5-runtime/src/exports/index.ts
  • packages/2-sql/5-runtime/src/plugins/budgets.ts
  • packages/2-sql/5-runtime/test/budgets.test.ts
💤 Files with no reviewable changes (1)
  • packages/1-framework/4-runtime-executor/src/exports/index.ts

@wmadden wmadden enabled auto-merge (rebase) March 25, 2026 16:06
@wmadden wmadden disabled auto-merge March 25, 2026 17:15
@jkomyno
Copy link
Contributor

jkomyno commented Mar 26, 2026

Code Review Summary

Overall: The domain move is architecturally sound — zero dependency violations, consistent with the lints migration precedent, resolves a long-standing TODO. AST-based dispatch is a clear improvement over fragile SQL string parsing. Public API is preserved. Good test coverage (20 cases).


🔴 P1 — Critical (3 findings)

1. EXPLAIN fallback is silently broken — driver interface mismatch
budgets.ts:19-24,35

The local DriverWithExplain interface uses a two-argument signature explain(sql, params), but the real postgres driver expects a single SqlExecuteRequest object explain({ sql, params }). The call on line 35 passes the SQL string as the first arg, meaning the real driver receives undefined for request.sql. The catch {} silently swallows the error, so explain: { enabled: true } has zero effect.

Suggested fix: Align the call with the canonical SqlQueryable interface:

const result = await driver.explain({ sql: plan.sql, params: [...plan.params] });

2. observedRows concurrency bug — shared mutable state
budgets.ts:199,205,227

The let observedRows counter is shared across all concurrent queries on the same plugin instance. beforeExecute resets it to 0, but if two queries interleave (normal in a server), Query B's beforeExecute resets Query A's counter mid-stream. This allows queries to exceed the budget undetected, or trigger false violations.

Suggested fix: Use a WeakMap<ExecutionPlan, number> keyed on the plan object for per-execution tracking.

3. Inconsistent shouldBlock semantics (OR vs AND)
budgets.ts:249,276,324

  • Row budget: shouldBlock = rowSeverity === 'error' || ctx.mode === 'strict' (OR — strict mode always blocks)
  • Latency budget: shouldBlock = latencySeverity === 'error' && ctx.mode === 'strict' (AND — both required)

This means severities: { rowCount: 'warn' } in strict mode is silently ignored — the plugin throws anyway. Inherited from old code, but this PR is a good moment to resolve it.

Suggested fix: Decide on a single convention and document it.


🟡 P2 — Important (5 findings)

4. Structural duplication between evaluateSelectAst and evaluateWithHeuristics — ~50 lines of near-identical control flow. Extract a shared checkRowBudget({ estimated, isUnbounded, source }, shouldBlock, ctx) helper.

5. afterExecute doesn't use emitBudgetViolation — Inline throw/warn logic at lines 249-257 despite the helper being extracted for exactly this purpose.

6. budgetError / lintError cross-file duplication — Structurally identical error factories in budgets.ts:99-116 and lints.ts:38-55. Since sql-runtime already imports from runtime-executor, consider using the existing runtimeError export.

7. Duplicated test fixturescreatePluginContext, createPlan, baseMeta, userTable, idCol are nearly identical between budgets.test.ts and lints.test.ts. Extract into shared test/helpers/plugin-fixtures.ts.

8. instanceof silent failure across package duplicates — If @prisma-next/sql-relational-core is duplicated in node_modules, instanceof SelectAst fails silently, degrading to the heuristic path with no observable warning. Consider logging when plan.ast exists but is not recognized. (Related: TML-2096)


🔵 P3 — Nice-to-Have (7 findings)

# Finding Location
9 Optional hasAggregateWithoutGroup param always passed — make required budgets.ts:130
10 7× repeated ctx as PluginContext<unknown, unknown, unknown> casts — narrow helper signature budgets.ts
11 void ctx.now() appears to be dead code budgets.ts:206
12 Unrelated help.ts template literal change — should be separate commit help.ts:96
13 No input validation on config options (maxRows: 0, NaN, negative) budgets.ts:192-197
14 findPlanRows recursive walk has no depth limit budgets.ts:60-97
15 Hoist explainEnabled const at plugin creation to avoid closure over options budgets.ts:366

Automated multi-agent review (6 agents: architecture, patterns, security, performance, simplicity, git history)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (5)
packages/2-sql/5-runtime/src/plugins/budgets.ts (5)

192-197: Consider validating configuration options.

No validation guards against invalid values like maxRows: 0, maxRows: -1, or NaN. These could cause confusing runtime behavior (e.g., 0 triggers immediate violations, negative values never trigger).

const maxRows = options?.maxRows ?? 10_000;
if (maxRows <= 0 || !Number.isFinite(maxRows)) {
  throw new Error(`budgets: maxRows must be a positive finite number, got ${maxRows}`);
}
// Similar for maxLatencyMs, defaultTableRows
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 192 - 197, Add
validation for budget configuration values so invalid numbers (0, negative, NaN,
Infinity) are rejected: check options?.maxRows (maxRows),
options?.defaultTableRows (defaultTableRows) and options?.maxLatencyMs
(maxLatencyMs) to ensure each is a positive finite number and throw a clear
Error if not; similarly validate severities (options?.severities.rowCount ->
rowSeverity and options?.severities.latency -> latencySeverity) to ensure they
are one of the expected strings (e.g., "error","warn","info") and throw if
invalid. Locate the assignments to maxRows, defaultTableRows, tableRows,
maxLatencyMs, rowSeverity and latencySeverity in budgets.ts and add these guards
immediately after those assignments so callers get fast, explicit failures
instead of surprising runtime behavior.

361-361: Hoist explainEnabled to plugin creation time.

This constant is derived from immutable options but recomputed on every beforeExecute call. Hoisting it alongside other option destructuring improves clarity and avoids repeated closure access.

   const rowSeverity = options?.severities?.rowCount ?? 'error';
   const latencySeverity = options?.severities?.latency ?? 'warn';
+  const explainEnabled = options?.explain?.enabled === true;

   // ... inside evaluateWithHeuristics:
-  const explainEnabled = options?.explain?.enabled === true;
   if (explainEnabled && isSelect && typeof ctx.driver === 'object' && ctx.driver !== null) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` at line 361, Hoist the
computed flag explainEnabled out of the beforeExecute handler and compute it
once at plugin creation time where other option destructuring occurs;
specifically, derive const explainEnabled = options?.explain?.enabled === true
alongside existing option unpacking and remove the per-call recomputation inside
beforeExecute so beforeExecute uses the precomputed explainEnabled constant
(reference symbols: explainEnabled, beforeExecute, options).

257-378: Structural duplication between evaluateSelectAst and evaluateWithHeuristics.

Both functions follow the same pattern: compute estimate → check unbounded → emit violation. Consider extracting a shared helper to reduce duplication:

Sketch of extracted helper
function checkRowBudget(
  ctx: PluginContext<unknown, unknown, unknown>,
  opts: {
    source: 'ast' | 'heuristic' | 'explain';
    estimated: number | null;
    isUnbounded: boolean;
    shouldBlock: boolean;
    maxRows: number;
  },
): void {
  const { source, estimated, isUnbounded, shouldBlock, maxRows } = opts;

  if (isUnbounded) {
    emitBudgetViolation(
      budgetError('BUDGET.ROWS_EXCEEDED', 'Unbounded SELECT query exceeds budget', {
        source,
        ...(estimated !== null && estimated >= maxRows ? { estimatedRows: estimated } : {}),
        maxRows,
      }),
      shouldBlock,
      ctx,
    );
    return;
  }

  if (estimated !== null && estimated > maxRows) {
    emitBudgetViolation(
      budgetError('BUDGET.ROWS_EXCEEDED', 'Estimated row count exceeds budget', {
        source,
        estimatedRows: estimated,
        maxRows,
      }),
      shouldBlock,
      ctx,
    );
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 257 - 378,
Extract a shared helper (e.g., checkRowBudget) and replace the duplicated
emit/condition logic in evaluateSelectAst and evaluateWithHeuristics: the helper
should accept ctx plus an opts object ({ source: 'ast'|'heuristic'|'explain',
estimated: number|null, isUnbounded: boolean, shouldBlock: boolean, maxRows:
number }) and implement the same behavior (if isUnbounded emit an "Unbounded
SELECT" violation including estimatedRows only when estimated !== null &&
estimated >= maxRows, otherwise emit with maxRows; if not unbounded and
estimated !== null && estimated > maxRows emit the "Estimated row count"
violation). Call this helper from evaluateSelectAst (pass source='ast') and from
evaluateWithHeuristics for the heuristic path (source='heuristic') and the
explain path (source='explain'), keeping the existing shouldBlock calculation
and ctx casts.

60-97: Consider adding recursion depth limit.

findPlanRows recursively walks the EXPLAIN output without a depth limit. While EXPLAIN results are typically shallow, a malformed or adversarial response could cause stack overflow. This is low-risk but worth hardening.

Proposed depth-limited version
-function findPlanRows(node: unknown): number | undefined {
+function findPlanRows(node: unknown, depth = 0): number | undefined {
+  if (depth > 50) {
+    return undefined;
+  }
   if (!node || typeof node !== 'object') {
     return undefined;
   }

   const explainNode = node as ExplainNode;
   const planRows = explainNode['Plan Rows'];
   if (typeof planRows === 'number') {
     return planRows;
   }

   if ('Plan' in explainNode && explainNode.Plan !== undefined) {
-    const nested = findPlanRows(explainNode.Plan);
+    const nested = findPlanRows(explainNode.Plan, depth + 1);
     if (nested !== undefined) {
       return nested;
     }
   }
   // ... similar changes for other recursive calls
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 60 - 97, The
recursive walker findPlanRows can overflow on deeply nested/malformed EXPLAIN
objects; add a recursion depth limit by introducing an optional depth parameter
(e.g., depth or remainingDepth) with a sensible default max (e.g., 50), check at
the top of findPlanRows and return undefined if depth is exhausted, and
decrement/pass the depth on every recursive call (calls from explainNode.Plan,
explainNode.Plans children, and Object.values traversal) so recursion stops
deterministically when the limit is reached.

208-214: instanceof check may silently fail if sql-relational-core is duplicated.

If npm installs multiple copies of @prisma-next/sql-relational-core, the instanceof SelectAst check will fail even for valid SelectAst instances (different class identities). The code then falls through to line 212-214 and returns early, silently skipping all estimation.

Consider logging when plan.ast exists but isn't recognized:

       if (plan.ast instanceof SelectAst) {
         return evaluateSelectAst(plan, plan.ast, ctx);
       }

       if (plan.ast) {
+        ctx.log.debug({
+          message: 'Unknown AST type in budgets plugin, skipping AST-based estimation',
+          astConstructor: plan.ast.constructor?.name,
+        });
         return;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 208 - 214, When
plan.ast exists but the instanceof SelectAst check fails we should not silently
return; add a warning log there so we can detect duplicated sql-relational-core
issues. Update the block that checks plan.ast/SelectAst (using the same symbols
plan.ast, SelectAst, evaluateSelectAst and ctx) to detect the case where
plan.ast is truthy but not an instance of SelectAst and call the logger (e.g.,
ctx.logger.warn/processLogger.warn) including the plan id/context,
plan.ast.constructor?.name and a short serialised preview of plan.ast to aid
debugging, then keep the early return as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 243-244: The latency budget uses AND semantics while row budgets
use OR, causing inconsistent blocking behavior; change the latency check so
shouldBlock for latency uses the same OR logic as rows (replace the
latencySeverity === 'error' && ctx.mode === 'strict' expression with
latencySeverity === 'error' || ctx.mode === 'strict' in the budgets plugin), and
update any related tests or BudgetsOptions docs/comments to reflect the unified
behavior (symbols to look for: shouldBlock, latencySeverity, rowSeverity,
ctx.mode).
- Line 206: The call to ctx.now() is dead because its return value is discarded;
either remove the call entirely or capture and use its result (e.g., const start
= ctx.now()) where appropriate. Locate the call to ctx.now() in the budgets
plugin (the line with "void ctx.now();") and either delete that statement if it
has no side effects, or assign its return to a meaningful variable
(startTime/now) and use it for timing/caching logic in the surrounding function.
- Around line 199-235: The shared closure variable observedRows causes
cross-request interference; replace it with a per-execution map (e.g., a WeakMap
keyed by the PluginContext or ExecutionPlan) so each execution tracks its own
count. In the budgets plugin, remove the top-level observedRows and introduce
observedRowsByCtx (WeakMap), set observedRowsByCtx.set(ctx, 0) in beforeExecute
(for SelectAst and heuristics paths), increment and read the count from
observedRowsByCtx using the ctx parameter inside onRow, and throw budgetError
when that per-context count exceeds maxRows; also clean up the entry
(observedRowsByCtx.delete(ctx)) in an appropriate lifecycle hook (e.g.,
afterExecute) to avoid leaks.
- Around line 19-40: The explain signature in DriverWithExplain doesn't match
the actual driver: change the interface and call site so explain accepts the
same single request object shape used by the driver (e.g. explain(request:
SqlExecuteRequest) or a compatible object), and update computeEstimatedRows to
pass the execution plan as that request (or construct a SqlExecuteRequest from
ExecutionPlan) when calling driver.explain(plan/req); keep the try/catch and
then call extractEstimatedRows(result.rows) as before so estimation works with
the real driver implementation.

---

Nitpick comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 192-197: Add validation for budget configuration values so invalid
numbers (0, negative, NaN, Infinity) are rejected: check options?.maxRows
(maxRows), options?.defaultTableRows (defaultTableRows) and
options?.maxLatencyMs (maxLatencyMs) to ensure each is a positive finite number
and throw a clear Error if not; similarly validate severities
(options?.severities.rowCount -> rowSeverity and options?.severities.latency ->
latencySeverity) to ensure they are one of the expected strings (e.g.,
"error","warn","info") and throw if invalid. Locate the assignments to maxRows,
defaultTableRows, tableRows, maxLatencyMs, rowSeverity and latencySeverity in
budgets.ts and add these guards immediately after those assignments so callers
get fast, explicit failures instead of surprising runtime behavior.
- Line 361: Hoist the computed flag explainEnabled out of the beforeExecute
handler and compute it once at plugin creation time where other option
destructuring occurs; specifically, derive const explainEnabled =
options?.explain?.enabled === true alongside existing option unpacking and
remove the per-call recomputation inside beforeExecute so beforeExecute uses the
precomputed explainEnabled constant (reference symbols: explainEnabled,
beforeExecute, options).
- Around line 257-378: Extract a shared helper (e.g., checkRowBudget) and
replace the duplicated emit/condition logic in evaluateSelectAst and
evaluateWithHeuristics: the helper should accept ctx plus an opts object ({
source: 'ast'|'heuristic'|'explain', estimated: number|null, isUnbounded:
boolean, shouldBlock: boolean, maxRows: number }) and implement the same
behavior (if isUnbounded emit an "Unbounded SELECT" violation including
estimatedRows only when estimated !== null && estimated >= maxRows, otherwise
emit with maxRows; if not unbounded and estimated !== null && estimated >
maxRows emit the "Estimated row count" violation). Call this helper from
evaluateSelectAst (pass source='ast') and from evaluateWithHeuristics for the
heuristic path (source='heuristic') and the explain path (source='explain'),
keeping the existing shouldBlock calculation and ctx casts.
- Around line 60-97: The recursive walker findPlanRows can overflow on deeply
nested/malformed EXPLAIN objects; add a recursion depth limit by introducing an
optional depth parameter (e.g., depth or remainingDepth) with a sensible default
max (e.g., 50), check at the top of findPlanRows and return undefined if depth
is exhausted, and decrement/pass the depth on every recursive call (calls from
explainNode.Plan, explainNode.Plans children, and Object.values traversal) so
recursion stops deterministically when the limit is reached.
- Around line 208-214: When plan.ast exists but the instanceof SelectAst check
fails we should not silently return; add a warning log there so we can detect
duplicated sql-relational-core issues. Update the block that checks
plan.ast/SelectAst (using the same symbols plan.ast, SelectAst,
evaluateSelectAst and ctx) to detect the case where plan.ast is truthy but not
an instance of SelectAst and call the logger (e.g.,
ctx.logger.warn/processLogger.warn) including the plan id/context,
plan.ast.constructor?.name and a short serialised preview of plan.ast to aid
debugging, then keep the early return as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 6770fe1b-eb16-4d33-9d51-7f70e088c5b3

📥 Commits

Reviewing files that changed from the base of the PR and between e796972 and 6406ac1.

📒 Files selected for processing (1)
  • packages/2-sql/5-runtime/src/plugins/budgets.ts

@wmadden
Copy link
Contributor Author

wmadden commented Mar 26, 2026

Thanks for the thorough review @jkomyno! Here's what I addressed and what I'm leaving for follow-ups.

Addressed in this PR

# Finding Commit
5 afterExecute doesn't use emitBudgetViolation 6406ac1
9 hasAggregateWithoutGroup optional but always passed — made required 6406ac1

Both were also flagged by CodeRabbit and are fixed in the latest push.

Not addressing (pre-existing, out of scope)

P1-1 — EXPLAIN driver interface mismatch: Confirmed — the local DriverWithExplain uses explain(sql, params) but SqlQueryable.explain takes a single SqlExecuteRequest object. This is pre-existing (moved unchanged from framework domain). The catch {} makes it fail safely (no estimate = conservative). Worth a follow-up ticket but changing the driver interface contract is out of scope for a domain-move PR.

P1-2 — observedRows concurrency: Pre-existing. The shared mutable counter was the original design. Fixing it changes the plugin's runtime behavior contract, which contradicts this PR's goal of a no-regression move. Follow-up ticket TML-2119

P1-3 — shouldBlock OR vs AND inconsistency: Pre-existing. The review acknowledges "inherited from old code." Changing severity semantics requires a deliberate design decision with its own spec/tests, not a side-effect of a move.

P2-4 — Structural duplication: The two functions handle materially different cases — evaluateSelectAst is synchronous (no EXPLAIN), evaluateWithHeuristics is async (EXPLAIN fallback). Extracting a shared helper would require either making the shared path async or splitting further, adding complexity for marginal gain given the functions are ~30 lines each.

P2-6 — budgetError/lintError duplication: Cross-cutting refactor affecting lints.ts too. Out of scope.

P2-7 — Test fixture duplication: Same — affects lints tests. Out of scope.

P2-8 — instanceof silent failure: Explicitly deferred to TML-2096 (kind-based dispatch).

P3-10 — Repeated ctx casts: Cosmetic. Required due to TypeScript generic invariance on PluginContext<T, A, D>.

P3-11 — void ctx.now(): Pre-existing. Captures the start timestamp for latency measurement as a side effect.

P3-12 — help.ts template literal: Biome auto-fix applied by lint-staged during pre-commit hook, not an intentional change.

P3-13/14/15 — Input validation, depth limit, hoist const: All pre-existing patterns, not introduced by this PR.


Happy to file follow-up tickets for P1-1 (EXPLAIN interface), and P1-3 (severity semantics) if you'd like those tracked.

…d code, shouldBlock

- Fix DriverWithExplain to use single-object `explain(request)` matching the
  actual postgres driver signature (was silently failing via catch block)
- Replace shared `observedRows` closure variable with per-plan WeakMap tracking
  to prevent cross-request row count interference in concurrent executions
- Remove dead `void ctx.now()` call whose return value was discarded
- Align latency shouldBlock to use OR logic (like row budgets) so strict mode
  consistently blocks for all budget types
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
packages/2-sql/5-runtime/src/plugins/budgets.ts (4)

249-249: Repeated type casts could be avoided with narrower helper signatures.

The ctx as PluginContext<unknown, unknown, unknown> cast appears 8 times. Consider making emitBudgetViolation generic or accepting a narrower interface containing only log and mode.

Narrower interface approach
+interface BudgetEmitContext {
+  readonly mode: 'strict' | 'permissive';
+  readonly log: { warn(event: unknown): void };
+}

 function emitBudgetViolation(
   error: ReturnType<typeof budgetError>,
   shouldBlock: boolean,
-  ctx: PluginContext<unknown, unknown, unknown>,
+  ctx: BudgetEmitContext,
 ): void {
   // ... implementation unchanged
 }

This allows passing ctx directly without casting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` at line 249, The repeated
casts of ctx as PluginContext<unknown, unknown, unknown> can be eliminated by
changing emitBudgetViolation to be generic or to accept a narrower interface
(e.g., type BudgetEmitterContext = { log: typeof ctx.log; mode: typeof ctx.mode
} or a generic type parameter C extends { log: Logger; mode: Mode }). Update the
signature of emitBudgetViolation (and any internal calls) to use the new
generic/narrow type so callers can pass ctx directly without casting; adjust any
call sites that rely on the old specific PluginContext type to match the new
parameter type while preserving existing behavior and exports.

99-116: Consider extracting a shared runtime error factory.

This budgetError factory duplicates the pattern in lints.ts (lintError). A shared createRuntimeError(category, code, message, details) factory would reduce duplication. This is a recommended refactor that could be addressed in a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 99 - 116, The
budgetError factory duplicates lintError; extract a shared helper like
createRuntimeError(category: string, code: string, message: string, details?:
Record<string, unknown>) and refactor both budgetError and lintError to call it;
implement createRuntimeError to construct an Error, set name to 'RuntimeError',
and assign code, category, severity: 'error', and details before returning so
budgetError and lintError become thin wrappers that pass 'BUDGET' or the lint
category plus the code/message/details into the new factory.

60-97: Unbounded recursion in findPlanRows could overflow the stack on deeply nested EXPLAIN output.

While unlikely in practice, a malformed or malicious EXPLAIN result with excessive nesting could cause a stack overflow. Consider adding a depth limit parameter.

Optional depth-limited fix
-function findPlanRows(node: unknown): number | undefined {
+function findPlanRows(node: unknown, depth = 0): number | undefined {
+  if (depth > 100) return undefined;
+
   if (!node || typeof node !== 'object') {
     return undefined;
   }

   const explainNode = node as ExplainNode;
   const planRows = explainNode['Plan Rows'];
   if (typeof planRows === 'number') {
     return planRows;
   }

   if ('Plan' in explainNode && explainNode.Plan !== undefined) {
-    const nested = findPlanRows(explainNode.Plan);
+    const nested = findPlanRows(explainNode.Plan, depth + 1);
     if (nested !== undefined) {
       return nested;
     }
   }

   if (Array.isArray(explainNode.Plans)) {
     for (const child of explainNode.Plans) {
-      const nested = findPlanRows(child);
+      const nested = findPlanRows(child, depth + 1);
       if (nested !== undefined) {
         return nested;
       }
     }
   }

   for (const value of Object.values(node as Record<string, unknown>)) {
     if (typeof value === 'object' && value !== null) {
-      const nested = findPlanRows(value);
+      const nested = findPlanRows(value, depth + 1);
       if (nested !== undefined) {
         return nested;
       }
     }
   }

   return undefined;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 60 - 97,
findPlanRows can recurse indefinitely; add a depth-limit parameter and stop
recursion once the limit is reached. Change the signature to accept an optional
remainingDepth (e.g., findPlanRows(node: unknown, remainingDepth = 50)),
immediately return undefined if remainingDepth <= 0, and pass remainingDepth - 1
on every recursive call (for explainNode.Plan, each child in explainNode.Plans,
and when iterating Object.values). Keep the default remainingDepth high enough
(e.g., 50) so typical EXPLAIN trees are unaffected and no caller changes are
required.

255-376: Duplication between evaluateSelectAst and evaluateWithHeuristics could be reduced.

Both functions share a similar pattern: estimate rows → check if unbounded → emit violation if exceeds budget. Consider extracting a shared checkRowBudget(estimated, isUnbounded, source, shouldBlock, ctx) helper to reduce duplication. This is a recommended refactor for maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 255 - 376, Both
evaluateSelectAst and evaluateWithHeuristics duplicate row-budget logic; extract
a helper (e.g. checkRowBudget) that accepts (estimated: number|null|undefined,
isUnbounded: boolean, source: 'ast'|'heuristic'|'explain', shouldBlock: boolean,
ctx: PluginContext<...>, maxRows: number) and encapsulates the decision tree: if
isUnbounded -> if estimated != null && estimated >= maxRows emitBudgetViolation
with source and estimatedRows then return else emitBudgetViolation without
estimatedRows then return; else if estimated != null && estimated > maxRows
emitBudgetViolation with estimatedRows; keep caller behavior identical (use same
budgetError codes/messages and shouldBlock casting) and call this helper from
evaluateSelectAst, evaluateWithHeuristics (including the explain branch) instead
of duplicating the checks, ensuring types and null/undefined handling for
estimated are preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Line 249: The repeated casts of ctx as PluginContext<unknown, unknown,
unknown> can be eliminated by changing emitBudgetViolation to be generic or to
accept a narrower interface (e.g., type BudgetEmitterContext = { log: typeof
ctx.log; mode: typeof ctx.mode } or a generic type parameter C extends { log:
Logger; mode: Mode }). Update the signature of emitBudgetViolation (and any
internal calls) to use the new generic/narrow type so callers can pass ctx
directly without casting; adjust any call sites that rely on the old specific
PluginContext type to match the new parameter type while preserving existing
behavior and exports.
- Around line 99-116: The budgetError factory duplicates lintError; extract a
shared helper like createRuntimeError(category: string, code: string, message:
string, details?: Record<string, unknown>) and refactor both budgetError and
lintError to call it; implement createRuntimeError to construct an Error, set
name to 'RuntimeError', and assign code, category, severity: 'error', and
details before returning so budgetError and lintError become thin wrappers that
pass 'BUDGET' or the lint category plus the code/message/details into the new
factory.
- Around line 60-97: findPlanRows can recurse indefinitely; add a depth-limit
parameter and stop recursion once the limit is reached. Change the signature to
accept an optional remainingDepth (e.g., findPlanRows(node: unknown,
remainingDepth = 50)), immediately return undefined if remainingDepth <= 0, and
pass remainingDepth - 1 on every recursive call (for explainNode.Plan, each
child in explainNode.Plans, and when iterating Object.values). Keep the default
remainingDepth high enough (e.g., 50) so typical EXPLAIN trees are unaffected
and no caller changes are required.
- Around line 255-376: Both evaluateSelectAst and evaluateWithHeuristics
duplicate row-budget logic; extract a helper (e.g. checkRowBudget) that accepts
(estimated: number|null|undefined, isUnbounded: boolean, source:
'ast'|'heuristic'|'explain', shouldBlock: boolean, ctx: PluginContext<...>,
maxRows: number) and encapsulates the decision tree: if isUnbounded -> if
estimated != null && estimated >= maxRows emitBudgetViolation with source and
estimatedRows then return else emitBudgetViolation without estimatedRows then
return; else if estimated != null && estimated > maxRows emitBudgetViolation
with estimatedRows; keep caller behavior identical (use same budgetError
codes/messages and shouldBlock casting) and call this helper from
evaluateSelectAst, evaluateWithHeuristics (including the explain branch) instead
of duplicating the checks, ensuring types and null/undefined handling for
estimated are preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 1a4d85cf-28d8-4375-926f-3e1d910197b0

📥 Commits

Reviewing files that changed from the base of the PR and between 6406ac1 and 362abb4.

📒 Files selected for processing (2)
  • packages/2-sql/5-runtime/src/plugins/budgets.ts
  • packages/2-sql/5-runtime/test/budgets.test.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
packages/2-sql/5-runtime/src/plugins/budgets.ts (2)

207-215: ⚠️ Potential issue | 🟠 Major

Don't skip heuristic fallback for unrecognized SELECT ASTs.

Line 211 returns on any truthy plan.ast that fails instanceof SelectAst. That means a duplicated @prisma-next/sql-relational-core copy, or any non-identical SelectAst constructor, disables all pre-execution row checks for SELECT plans instead of falling back to the SQL-text heuristics.

Suggested fix
       if (plan.ast instanceof SelectAst) {
         return evaluateSelectAst(plan, plan.ast, ctx);
       }

       if (plan.ast) {
-        return;
+        const sqlUpper = plan.sql.trimStart().toUpperCase();
+        if (sqlUpper.startsWith('SELECT')) {
+          return evaluateWithHeuristics(plan, ctx);
+        }
+        return;
       }

       return evaluateWithHeuristics(plan, ctx);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 207 - 215, The
current early return when plan.ast is truthy but not an instance of SelectAst
skips heuristic fallback; instead, ensure non-SelectAst truthy ASTs fall through
to the heuristics. Change the logic around SelectAst/plan.ast so that if
plan.ast instanceof SelectAst you call evaluateSelectAst(plan, plan.ast, ctx),
otherwise (when plan.ast is truthy but not SelectAst) call or fall through to
evaluateWithHeuristics(plan, ctx) rather than returning; update the block that
references SelectAst, evaluateSelectAst, evaluateWithHeuristics and plan.ast
accordingly.

241-249: ⚠️ Potential issue | 🟠 Major

latency: 'error' is still downgraded to warn in permissive mode.

Line 242 only blocks when both knobs are set, so severities.latency: 'error' merely logs in permissive mode. Row budgets on Line 269 do not behave that way, which makes the public budget API internally inconsistent again. Either align the blocking rule or explicitly split/document the semantics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 241 - 249, The
latency budget currently only blocks when latencySeverity === 'error' AND
ctx.mode === 'strict', which causes severities.latency: 'error' to be downgraded
to a log in permissive mode; change the blocking logic to match row-budget
semantics by making shouldBlock true whenever latencySeverity === 'error'
(regardless of ctx.mode) and only block for 'warn' when ctx.mode === 'strict'
(e.g. const shouldBlock = latencySeverity === 'error' || (latencySeverity ===
'warn' && ctx.mode === 'strict')), then pass that shouldBlock into
emitBudgetViolation (and ensure this matches the rows severity handling used
elsewhere).
🧹 Nitpick comments (1)
packages/2-sql/5-runtime/src/plugins/budgets.ts (1)

189-197: Validate BudgetsOptions before closing over it.

NaN, Infinity, or negative numbers here silently disable or invert checks (observedRows > NaN is always false, Math.min(NaN, tableEstimate) stays NaN). Normalize this config once at construction time instead of trusting raw values.

As per coding guidelines, "Use config validation and normalization patterns with Arktype" and "Use schema validators (Arktype) for structural validation of required fields, types, and shapes rather than redundant manual checks for these properties."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-sql/5-runtime/src/plugins/budgets.ts` around lines 189 - 197,
Normalize and validate the BudgetsOptions inside the budgets factory before
closing over it: create an Arktype schema for BudgetsOptions and run options
through it, coercing/normalizing numeric fields (maxRows, defaultTableRows,
maxLatencyMs and every entry in tableRows) to finite non-negative integers (use
Math.floor/Math.max(0, Number(value)) or equivalent) and replacing
NaN/Infinity/negatives with the configured defaults; also normalize severities
to allowed values and fall back to 'error'/'warn' when missing; then assign the
normalized values to the closed-over variables (maxRows, defaultTableRows,
tableRows, maxLatencyMs, rowSeverity, latencySeverity) so the rest of the code
that uses budgets, tableRows, and severities operates on safe, validated data.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 344-356: The heuristic pre-execution row estimate branch should
only run for SELECT plans: update the conditional that checks estimated !== null
in the budgets plugin (the block that calls emitBudgetViolation with
budgetError('BUDGET.ROWS_EXCEEDED', ...)) to also require isSelect, so only when
isSelect is true do you emit the pre-execution rows-exceeded violation; leave
onRow() behavior unchanged for non-SELECT queries. Ensure you reference the
existing estimated variable, the isSelect boolean, and the emitBudgetViolation /
budgetError call so the change is applied to that exact branch.

---

Duplicate comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 207-215: The current early return when plan.ast is truthy but not
an instance of SelectAst skips heuristic fallback; instead, ensure non-SelectAst
truthy ASTs fall through to the heuristics. Change the logic around
SelectAst/plan.ast so that if plan.ast instanceof SelectAst you call
evaluateSelectAst(plan, plan.ast, ctx), otherwise (when plan.ast is truthy but
not SelectAst) call or fall through to evaluateWithHeuristics(plan, ctx) rather
than returning; update the block that references SelectAst, evaluateSelectAst,
evaluateWithHeuristics and plan.ast accordingly.
- Around line 241-249: The latency budget currently only blocks when
latencySeverity === 'error' AND ctx.mode === 'strict', which causes
severities.latency: 'error' to be downgraded to a log in permissive mode; change
the blocking logic to match row-budget semantics by making shouldBlock true
whenever latencySeverity === 'error' (regardless of ctx.mode) and only block for
'warn' when ctx.mode === 'strict' (e.g. const shouldBlock = latencySeverity ===
'error' || (latencySeverity === 'warn' && ctx.mode === 'strict')), then pass
that shouldBlock into emitBudgetViolation (and ensure this matches the rows
severity handling used elsewhere).

---

Nitpick comments:
In `@packages/2-sql/5-runtime/src/plugins/budgets.ts`:
- Around line 189-197: Normalize and validate the BudgetsOptions inside the
budgets factory before closing over it: create an Arktype schema for
BudgetsOptions and run options through it, coercing/normalizing numeric fields
(maxRows, defaultTableRows, maxLatencyMs and every entry in tableRows) to finite
non-negative integers (use Math.floor/Math.max(0, Number(value)) or equivalent)
and replacing NaN/Infinity/negatives with the configured defaults; also
normalize severities to allowed values and fall back to 'error'/'warn' when
missing; then assign the normalized values to the closed-over variables
(maxRows, defaultTableRows, tableRows, maxLatencyMs, rowSeverity,
latencySeverity) so the rest of the code that uses budgets, tableRows, and
severities operates on safe, validated data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 9736e264-f286-4cae-b265-0154afe99491

📥 Commits

Reviewing files that changed from the base of the PR and between 362abb4 and ab52b18.

📒 Files selected for processing (2)
  • packages/2-sql/5-runtime/src/plugins/budgets.ts
  • packages/2-sql/5-runtime/test/budgets.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/2-sql/5-runtime/test/budgets.test.ts

@wmadden wmadden merged commit 9fd4f0c into main Mar 26, 2026
15 checks passed
@wmadden wmadden deleted the tml-2099-move-budgets-plugin-from-framework-domain-to-sql-domain-next-to-lints branch March 26, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants