Skip to content

feat(sql-runtime): add beforeCompile middleware hook#373

Merged
SevInf merged 3 commits intomainfrom
writeable-middleware
Apr 27, 2026
Merged

feat(sql-runtime): add beforeCompile middleware hook#373
SevInf merged 3 commits intomainfrom
writeable-middleware

Conversation

@SevInf
Copy link
Copy Markdown
Contributor

@SevInf SevInf commented Apr 23, 2026

closes TML-2306

Intent

Enable SQL middleware to rewrite the query AST between lane .build() and adapter lowering, unblocking composable cross-cutting concerns (soft-delete, tenant isolation, audit scoping). First deliverable of TML-2143; short-circuit caching and annotation DSL follow later.

Change map

The rewrite hook is SQL-family-scoped: it runs inside SqlRuntimeImpl before lowerSqlPlan(), and does not alter RuntimeCoreImpl's execute pipeline. The cross-family Middleware<TContract> interface gains a generic beforeCompile?(draft: { ast: unknown; meta }, ctx) so future families can adopt the same pattern; SqlMiddleware narrows it to DraftPlan { ast: AnyQueryAst }.

The story

Starting point: observation-only middleware — beforeExecute, onRow, afterExecute all returned Promise<void>. Users needing filters like soft-delete had no supported path.

SqlMiddleware gains beforeCompile. Typed DraftPlan → DraftPlan | undefined. Middlewares run in registration order; each sees the predecessor's output. Returning undefined (or a draft whose ast reference equals the input's) passes through silently. A new ast reference replaces the current draft and emits a middleware.rewrite event via ctx.log.debug naming the middleware. adapter.lower() runs exactly once after the chain completes.

The hook lives in SqlRuntimeImpl, not in core. Lane .build() already returns a pre-lowering SqlQueryPlan per ADR 016, and lowerSqlPlan() is invoked inside SqlRuntimeImpl.toExecutionPlan() — so the family runtime already owns lowering. The chain iterator slots in front of lowering with zero changes to the cross-family executor. Raw-SQL plans (no AST) bypass the hook naturally.

RuntimeCore was parameterized with TMiddleware and exposes middleware and middlewareContext as readonly; SqlRuntimeImpl reads from core rather than maintaining duplicate state. RuntimeOptions.middleware tightened from Middleware<TContract>[] to SqlMiddleware[]. RuntimeLog gained an optional debug? for rewrite tracing.

Existing SqlMiddleware implementations (lints, budgets, createTelemetryMiddleware) were typed as SqlMiddleware directly, removing earlier casts and the generic Middleware<TContract> indirection where it wasn't needed.

Behavior changes & evidence

  • End-to-end rewrite through real Postgres: rewriting-middleware.integration.test.ts registers a middleware that injects WHERE users.id = 1, executes a DSL query with no explicit filter, asserts only row 1 comes back; second case composes middleware + user WHERE and verifies AND semantics.
  • Lowering runs once regardless of chain length: sql-runtime.test.ts spies on adapter.lower, registers two chained middlewares, asserts a single call that receives the post-rewrite AST.
  • Chain semantics: before-compile-chain.test.ts covers passthrough (void and same-ref), chained rewrites in registration order, log-event fidelity, hook-less middleware skip, and error propagation.
  • Raw-SQL bypass: covered in sql-runtime.test.ts.

Compatibility / migration / risk

  • Additive SPI changes: debug? on RuntimeLog, generic beforeCompile? on Middleware<TContract>. Both optional; existing implementations compile unchanged.
  • Type tightening: RuntimeOptions.middleware is now readonly SqlMiddleware[] (was Middleware<TContract>[]). In-repo callers (postgres.ts, sqlite.ts, integration utils, demo) updated in the same PR. External callers passing generic middleware without familyId still satisfy the type (SqlMiddleware.familyId is optional); callers setting familyId: 'mongo' would now fail typecheck — which is the intended behavior.
  • SqlMiddleware.familyId relaxed from required 'sql' to optional 'sql' — compatible with cross-family middleware like createTelemetryMiddleware that omit familyId.
  • No changes to RuntimeCoreImpl execute semantics, lane .build(), or the lowering path beyond chain integration.
  • Middleware authors constructing LiteralExpr.of(userInput) from untrusted input bypass parameterization; documented in the new subsystem section and TSDoc.

Follow-ups / open questions

  • Short-circuiting with static results (caching middleware) and user-authored annotation DSL — tracked under TML-2143, not this PR.
  • Mongo-family rewriteable-AST: the generic hook is reserved on Middleware<TContract>, but Mongo would need its own draft shape and invocation site.
  • ADR 014 ("Runtime Hook API") is formally stale; this PR updates the subsystem doc but does not remove the ADR.

Non-goals / intentionally out of scope

  • dependsOn / conflictsWith middleware ordering metadata — registration-array order is the sole source of truth.
  • A rewriteTrail annotation on the plan — logs are the audit trail; rejected to keep plan shape unchanged.
  • Pre-lowering validation of middleware output — authors are responsible for valid AST; invalid ASTs surface via runtimeError.
  • Retyping ExecutionPlan.ast on existing hooks — beforeExecute / onRow / afterExecute keep plan.ast: unknown for v1; beforeCompile is the new typed surface.

Summary by CodeRabbit

Release Notes

  • New Features

    • SQL middleware can now intercept and transform queries before compilation via beforeCompile hook with debug event tracking.
    • Middleware chaining with ordered sequential execution.
  • Documentation

    • Expanded runtime framework documentation with comprehensive SQL execution pipeline specifications and middleware rewriting semantics.
  • Tests

    • New integration tests validating middleware query transformation behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

This change introduces a pre-lowering SQL query rewriting mechanism via a new optional beforeCompile hook in the middleware API. Middleware receives a DraftPlan (AST + metadata) before SQL compilation and can transform the query; a helper function runBeforeCompileChain orchestrates sequential execution with debug event emission on rewrites. The SQL runtime integrates this chain before adapter.lower(). Type-level updates propagate SqlMiddleware throughout, replacing generic Middleware<TContract>.

Changes

Cohort / File(s) Summary
Core Middleware Type System
packages/1-framework/4-runtime/runtime-executor/src/middleware/types.ts, packages/2-sql/5-runtime/src/middleware/sql-middleware.ts, packages/1-framework/1-core/framework-components/src/runtime-middleware.ts
Introduces GenericDraftPlan and DraftPlan interfaces, adds optional beforeCompile hook to Middleware and SqlMiddleware, adds optional debug method to RuntimeLog.
SQL Runtime Infrastructure
packages/2-sql/5-runtime/src/middleware/before-compile-chain.ts, packages/2-sql/5-runtime/src/sql-runtime.ts
Implements runBeforeCompileChain helper for sequential middleware execution with debug logging, integrates chain into SQL compilation pipeline via async toExecutionPlan.
Core Runtime Generics
packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts
Adds TMiddleware generic parameter to RuntimeCoreOptions, RuntimeCore, and createRuntimeCore for type-safe middleware storage and exposure.
Existing Middleware
packages/2-sql/5-runtime/src/middleware/budgets.ts, packages/2-sql/5-runtime/src/middleware/lints.ts
Retyped to use SqlMiddleware instead of generic Middleware<TContract>; context parameters updated to SqlMiddlewareContext.
Extensions
packages/3-extensions/postgres/src/runtime/postgres.ts, packages/3-extensions/sqlite/src/runtime/sqlite.ts, packages/3-extensions/middleware-telemetry/src/telemetry-middleware.ts
Updated middleware typing from Middleware<TContract> to SqlMiddleware; contract generics refactored to decouple middleware; telemetry factory return type annotated with optional familyId and targetId.
Documentation & Examples
docs/architecture/subsystems/4. Runtime & Middleware Framework.md, examples/prisma-next-demo/src/prisma-no-emit/runtime.ts
Documented beforeCompile lifecycle with worked soft-delete example; updated runtime factory parameter type to SqlMiddleware[].
Tests
packages/2-sql/5-runtime/test/before-compile-chain.test.ts, packages/2-sql/5-runtime/test/budgets.test.ts, packages/2-sql/5-runtime/test/lints.test.ts, packages/2-sql/5-runtime/test/sql-runtime.test.ts, test/integration/test/rewriting-middleware.integration.test.ts, test/integration/test/utils.ts
Added comprehensive unit tests for beforeCompile chaining and debug events; added integration tests for middleware AST rewriting on Postgres; updated context typing to use SqlMiddlewareContext.

Sequence Diagram

sequenceDiagram
    participant Runtime as SQL Runtime
    participant Chain as beforeCompile Chain
    participant M1 as Middleware 1
    participant M2 as Middleware 2
    participant Adapter as Adapter

    Runtime->>Chain: runBeforeCompileChain(middlewares, draft)
    
    Chain->>M1: beforeCompile(draft, ctx)
    alt M1 returns new draft
        M1-->>Chain: {ast: newAst, meta}
        Chain->>Chain: emit debug.middleware.rewrite event
        Note over Chain: draft = {ast: newAst, meta}
    else M1 returns undefined
        M1-->>Chain: undefined
        Note over Chain: draft unchanged
    end
    
    Chain->>M2: beforeCompile(draft, ctx)
    alt M2 returns new draft
        M2-->>Chain: {ast: newAst2, meta}
        Chain->>Chain: emit debug.middleware.rewrite event
        Note over Chain: draft = {ast: newAst2, meta}
    else M2 returns undefined
        M2-->>Chain: undefined
        Note over Chain: draft unchanged
    end
    
    Chain-->>Runtime: final DraftPlan
    Runtime->>Adapter: lower(ast) → {sql, params}
    Adapter-->>Runtime: ExecutionPlan
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Possibly related PRs

No additional related PRs identified in the provided search results beyond the current implementation.

Poem

🐰 A whisker-twitch of SQL grace,
Before the lower sets its pace,
Middleware hops through draft's embrace,
Rewrites the where with gentle trace.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(sql-runtime): add beforeCompile middleware hook' accurately describes the main change: adding a new beforeCompile hook to SQL middleware for AST rewriting before lowering.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 writeable-middleware

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 23, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@373

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@373

@prisma-next/sql-runtime

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

@prisma-next/family-sql

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

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@373

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@373

@prisma-next/extension-paradedb

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

@prisma-next/extension-pgvector

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

@prisma-next/postgres

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

@prisma-next/sql-orm-client

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

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@373

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@373

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@373

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@373

@prisma-next/contract

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

@prisma-next/utils

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

@prisma-next/config

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

@prisma-next/errors

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

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@373

@prisma-next/operations

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

@prisma-next/ts-render

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ts-render@373

@prisma-next/contract-authoring

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

@prisma-next/ids

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

@prisma-next/psl-parser

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

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@373

@prisma-next/cli

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

@prisma-next/emitter

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

@prisma-next/migration-tools

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

prisma-next

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

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

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

@prisma-next/runtime-executor

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

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@373

@prisma-next/mongo-contract

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

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@373

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@373

@prisma-next/mongo-contract-ts

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

@prisma-next/mongo-emitter

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

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@373

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@373

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@373

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@373

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@373

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@373

@prisma-next/sql-contract

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

@prisma-next/sql-errors

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

@prisma-next/sql-operations

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

@prisma-next/sql-schema-ir

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

@prisma-next/sql-contract-psl

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

@prisma-next/sql-contract-ts

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

@prisma-next/sql-contract-emitter

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

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

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

@prisma-next/sql-relational-core

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

@prisma-next/sql-builder

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

@prisma-next/target-postgres

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

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@373

@prisma-next/adapter-postgres

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

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@373

@prisma-next/driver-postgres

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

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@373

commit: b8cde9e

SevInf added a commit that referenced this pull request Apr 23, 2026
…ning

Addresses review on #373:
- The previous composition test only exercised middleware defined inside
  the test, not any real system integration. Removed.
- New test registers two middlewares that each add a different predicate
  (id >= 2, id <= 3); verifies the chained AND narrows results to rows
  2-3 and that both rewrites emit debug log events in registration order.
- Factored runtime creation into buildRuntime() so each test configures
  its own middleware set against a shared driver.
SevInf added a commit that referenced this pull request Apr 23, 2026
…ning

Addresses review on #373:
- The previous composition test only exercised middleware defined inside
  the test, not any real system integration. Removed.
- New test registers two middlewares that each add a different predicate
  (id >= 2, id <= 3); verifies the chained AND narrows results to rows
  2-3 and that both rewrites emit debug log events in registration order.
- Factored runtime creation into buildRuntime() so each test configures
  its own middleware set against a shared driver.
@SevInf SevInf force-pushed the writeable-middleware branch from 15c9562 to 9485f29 Compare April 23, 2026 14:50
SevInf added 3 commits April 26, 2026 18:33
Enables SQL middleware to rewrite the query AST between lane build() and
adapter lowering, unblocking cross-cutting concerns like soft-delete filters
and tenant isolation. First deliverable of TML-2306 / TML-2143.

- SqlMiddleware.beforeCompile receives a typed DraftPlan { ast: AnyQueryAst,
  meta } and may return a rewritten draft; chain runs in registration order
  inside SqlRuntimeImpl.toExecutionPlan before lowerSqlPlan.
- Detect rewrite via reference inequality; passthrough (void / same-ref) is
  free. Each rewrite logged via ctx.log.debug with middleware name + lane.
  No plan annotation.
- RuntimeCore parameterized with TMiddleware so SqlRuntimeImpl carries
  SqlMiddleware directly; middleware and middlewareContext exposed as
  readonly so family runtimes avoid duplicate storage.
- RuntimeLog gains optional debug; RuntimeOptions.middleware tightened to
  readonly SqlMiddleware[]; lints/budgets/telemetry typed accordingly.
…c hook

- End-to-end integration test (real Postgres, DSL lane) proves the soft-delete
  rewrite flows through adapter.lower and produces filtered rows.
- Unit test verifies adapter.lower runs exactly once per execute and receives
  the post-rewrite AST, regardless of chain length.
- Middleware<TContract> regains a generic beforeCompile (ast: unknown)
  alongside the narrowed SqlMiddleware signature. Now that TMiddleware
  threads through RuntimeCore, the framework-layer hook is safe to expose
  and opens the door for cross-family rewriting middleware.
- Docs: new "Rewriting ASTs" section in the Runtime & Middleware subsystem
  doc and TSDoc on SqlMiddleware.beforeCompile; both carry the SQL-injection
  warning for Literal(userInput).
- Close-out: delete the transient projects/middleware-rewriteable-ast/
  workspace; durable design notes are in docs/architecture docs/.
…ning

Addresses review on #373:
- The previous composition test only exercised middleware defined inside
  the test, not any real system integration. Removed.
- New test registers two middlewares that each add a different predicate
  (id >= 2, id <= 3); verifies the chained AND narrows results to rows
  2-3 and that both rewrites emit debug log events in registration order.
- Factored runtime creation into buildRuntime() so each test configures
  its own middleware set against a shared driver.
@wmadden wmadden force-pushed the writeable-middleware branch from 9485f29 to b8cde9e Compare April 26, 2026 16:33
Copy link
Copy Markdown

@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

🧹 Nitpick comments (6)
packages/1-framework/4-runtime/runtime-executor/src/middleware/types.ts (1)

17-24: Doc comment references a non-existent member type.

The JSDoc says "via SqlMiddleware.DraftPlan" but DraftPlan is a sibling top-level export of SqlMiddleware in packages/2-sql/5-runtime/src/middleware/sql-middleware.ts, not a nested member. Consider rephrasing to e.g. "via the SQL runtime's DraftPlan type" to avoid implying a member access pattern that doesn't exist.

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

In `@packages/1-framework/4-runtime/runtime-executor/src/middleware/types.ts`
around lines 17 - 24, The doc comment on GenericDraftPlan incorrectly references
a non-existent nested member `SqlMiddleware.DraftPlan`; update the comment to
avoid implying member access — for example rephrase to "via the SQL runtime's
`DraftPlan` type" or "via the top-level `DraftPlan` export in the SQL runtime"
so it accurately refers to the top-level `DraftPlan` export found alongside
`SqlMiddleware`.
test/integration/test/rewriting-middleware.integration.test.ts (2)

79-115: Schema setup creates four tables that are never queried.

The test only exercises users, but posts, comments, profiles, and articles (and the vector extension) are also created. If setupTestDatabase requires these to match the fixture contract's storage.tables, the comment block here is fine; otherwise, trimming would speed the integration test up considerably.

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

In `@test/integration/test/rewriting-middleware.integration.test.ts` around lines
79 - 115, The test setup creates unnecessary schema objects (CREATE EXTENSION IF
NOT EXISTS vector; CREATE TABLE posts, comments, profiles, articles) though the
test only queries users; remove the extra CREATE statements (or conditionally
create them only when setupTestDatabase/fixture storage.tables requires them) so
setup only runs the CREATE TABLE users block, or ensure the fixture contract
explicitly requires those tables before keeping them; look for the CREATE
EXTENSION IF NOT EXISTS vector and the CREATE TABLE
posts/comments/profiles/articles statements in the test and trim or gate them
accordingly.

223-223: Numeric sort uses lexicographic comparator.

rows.map((r) => r.id).sort() works for [2, 3] but is fragile if the test (or its data) ever grows past single digits (e.g., [10, 2] would sort to [10, 2] lexicographically). Use a numeric comparator for clarity and future-proofing.

📝 Proposed fix
-    expect(rows.map((r) => r.id).sort()).toEqual([2, 3]);
+    expect(rows.map((r) => r.id).sort((a, b) => a - b)).toEqual([2, 3]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/integration/test/rewriting-middleware.integration.test.ts` at line 223,
The test currently calls rows.map((r) => r.id).sort() which uses lexicographic
sorting; update the assertion to sort numerically by using a numeric comparator
(e.g., sort((a, b) => a - b)) so the array of ids is ordered by numeric value
before comparing to [2, 3]; locate the assertion around the expect in
rewriting-middleware.integration.test.ts that references rows.map((r) =>
r.id).sort() and replace the sort call with a numeric comparator.
packages/2-sql/5-runtime/test/before-compile-chain.test.ts (1)

95-117: Consider also asserting reference equality with the input draft for passthrough cases.

In the "treats a returned draft with same ast reference as passthrough" test (Line 87-90), result.ast === draft.ast is asserted but not result === draft. The implementation guarantees the original draft object (not the new one returned by the middleware) is propagated when result.ast === current.ast, so adding expect(result).toBe(draft) would more directly pin down the documented "no replacement" semantics.

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

In `@packages/2-sql/5-runtime/test/before-compile-chain.test.ts` around lines 95 -
117, In the existing test "treats a returned draft with same ast reference as
passthrough" add an assertion that the returned draft object itself is the same
reference as the input draft: after calling runBeforeCompileChain(...) and after
the existing expect(result.ast === draft.ast) assertion, add
expect(result).toBe(draft); this enforces the implementation contract that when
ast references are unchanged the original draft object is propagated (locate the
test by its name and the runBeforeCompileChain call).
packages/2-sql/5-runtime/test/sql-runtime.test.ts (1)

491-527: rewriteB.beforeCompile overwrites rewriteA's WHERE rather than combining predicates.

rewriteA adds users.a = 1, then rewriteB calls draft.ast.withWhere(...) directly, replacing the predicate set by rewriteA. The final lowered AST therefore only contains b = 2. The test still validates "lower is called exactly once" (its stated intent), but the chained-rewrite semantics it visually implies aren't actually exercised here. If you want this test to also assert proper chain composition, combine via AndExpr like the unit test in before-compile-chain.test.ts does; otherwise the existing assertion stands.

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

In `@packages/2-sql/5-runtime/test/sql-runtime.test.ts` around lines 491 - 527,
rewriteB.beforeCompile replaces the WHERE set by rewriteA because it calls
draft.ast.withWhere(...) directly; change rewriteB to combine its predicate with
any existing predicate instead of overwriting it — e.g., inside
rewriteB.beforeCompile read draft.ast.where and, if present, wrap them with an
AndExpr (combine ColumnRef.of('users','b') predicate with existing
draft.ast.where), then return the updated draft with the combined where;
reference rewriteA, rewriteB.beforeCompile, draft.ast.withWhere, and AndExpr to
locate and implement the change consistent with the chained-rewrite approach
used in before-compile-chain.test.ts.
packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts (1)

308-310: Optional: thread TMiddleware through the inner generator's self parameter.

Now that RuntimeCoreImpl is generic over TMiddleware, the inner generator still types self as RuntimeCoreImpl<TContract, TDriver> (defaulting TMiddleware to Middleware<TContract>). It's only safe today because TMiddleware is used covariantly via readonly TMiddleware[]; aligning the annotation keeps the type story explicit and avoids relying on that subtyping.

♻️ Suggested change
     const iterator = async function* (
-      self: RuntimeCoreImpl<TContract, TDriver>,
+      self: RuntimeCoreImpl<TContract, TDriver, TMiddleware>,
     ): AsyncGenerator<Row, void, unknown> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts` around
lines 308 - 310, The inner async generator `iterator` currently types its `self`
parameter as RuntimeCoreImpl<TContract, TDriver> which omits the new TMiddleware
generic; change the `self` parameter type to RuntimeCoreImpl<TContract, TDriver,
TMiddleware> (or the exact three‑param form used by the class) so the generator
is explicitly generic over TMiddleware; update the iterator signature to include
the TMiddleware generic on its declaration to match the outer RuntimeCoreImpl
generic parameters and preserve correct variance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/architecture` docs/subsystems/4. Runtime & Middleware Framework.md:
- Around line 275-294: The example middleware softDelete uses AndExpr but the
import list only includes BinaryExpr, ColumnRef, and LiteralExpr; update the
import statement to also import AndExpr from
'@prisma-next/sql-relational-core/ast' so AndExpr.of(...) is defined for the
beforeCompile function in the softDelete middleware.

---

Nitpick comments:
In `@packages/1-framework/4-runtime/runtime-executor/src/middleware/types.ts`:
- Around line 17-24: The doc comment on GenericDraftPlan incorrectly references
a non-existent nested member `SqlMiddleware.DraftPlan`; update the comment to
avoid implying member access — for example rephrase to "via the SQL runtime's
`DraftPlan` type" or "via the top-level `DraftPlan` export in the SQL runtime"
so it accurately refers to the top-level `DraftPlan` export found alongside
`SqlMiddleware`.

In `@packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts`:
- Around line 308-310: The inner async generator `iterator` currently types its
`self` parameter as RuntimeCoreImpl<TContract, TDriver> which omits the new
TMiddleware generic; change the `self` parameter type to
RuntimeCoreImpl<TContract, TDriver, TMiddleware> (or the exact three‑param form
used by the class) so the generator is explicitly generic over TMiddleware;
update the iterator signature to include the TMiddleware generic on its
declaration to match the outer RuntimeCoreImpl generic parameters and preserve
correct variance.

In `@packages/2-sql/5-runtime/test/before-compile-chain.test.ts`:
- Around line 95-117: In the existing test "treats a returned draft with same
ast reference as passthrough" add an assertion that the returned draft object
itself is the same reference as the input draft: after calling
runBeforeCompileChain(...) and after the existing expect(result.ast ===
draft.ast) assertion, add expect(result).toBe(draft); this enforces the
implementation contract that when ast references are unchanged the original
draft object is propagated (locate the test by its name and the
runBeforeCompileChain call).

In `@packages/2-sql/5-runtime/test/sql-runtime.test.ts`:
- Around line 491-527: rewriteB.beforeCompile replaces the WHERE set by rewriteA
because it calls draft.ast.withWhere(...) directly; change rewriteB to combine
its predicate with any existing predicate instead of overwriting it — e.g.,
inside rewriteB.beforeCompile read draft.ast.where and, if present, wrap them
with an AndExpr (combine ColumnRef.of('users','b') predicate with existing
draft.ast.where), then return the updated draft with the combined where;
reference rewriteA, rewriteB.beforeCompile, draft.ast.withWhere, and AndExpr to
locate and implement the change consistent with the chained-rewrite approach
used in before-compile-chain.test.ts.

In `@test/integration/test/rewriting-middleware.integration.test.ts`:
- Around line 79-115: The test setup creates unnecessary schema objects (CREATE
EXTENSION IF NOT EXISTS vector; CREATE TABLE posts, comments, profiles,
articles) though the test only queries users; remove the extra CREATE statements
(or conditionally create them only when setupTestDatabase/fixture storage.tables
requires them) so setup only runs the CREATE TABLE users block, or ensure the
fixture contract explicitly requires those tables before keeping them; look for
the CREATE EXTENSION IF NOT EXISTS vector and the CREATE TABLE
posts/comments/profiles/articles statements in the test and trim or gate them
accordingly.
- Line 223: The test currently calls rows.map((r) => r.id).sort() which uses
lexicographic sorting; update the assertion to sort numerically by using a
numeric comparator (e.g., sort((a, b) => a - b)) so the array of ids is ordered
by numeric value before comparing to [2, 3]; locate the assertion around the
expect in rewriting-middleware.integration.test.ts that references rows.map((r)
=> r.id).sort() and replace the sort call with a numeric comparator.
🪄 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: 8fc29a88-9f30-4944-b697-f91e0b90b2ad

📥 Commits

Reviewing files that changed from the base of the PR and between cd17e07 and b8cde9e.

📒 Files selected for processing (19)
  • docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md
  • examples/prisma-next-demo/src/prisma-no-emit/runtime.ts
  • packages/1-framework/1-core/framework-components/src/runtime-middleware.ts
  • packages/1-framework/4-runtime/runtime-executor/src/middleware/types.ts
  • packages/1-framework/4-runtime/runtime-executor/src/runtime-core.ts
  • packages/2-sql/5-runtime/src/middleware/before-compile-chain.ts
  • packages/2-sql/5-runtime/src/middleware/budgets.ts
  • packages/2-sql/5-runtime/src/middleware/lints.ts
  • packages/2-sql/5-runtime/src/middleware/sql-middleware.ts
  • packages/2-sql/5-runtime/src/sql-runtime.ts
  • packages/2-sql/5-runtime/test/before-compile-chain.test.ts
  • packages/2-sql/5-runtime/test/budgets.test.ts
  • packages/2-sql/5-runtime/test/lints.test.ts
  • packages/2-sql/5-runtime/test/sql-runtime.test.ts
  • packages/3-extensions/middleware-telemetry/src/telemetry-middleware.ts
  • packages/3-extensions/postgres/src/runtime/postgres.ts
  • packages/3-extensions/sqlite/src/runtime/sqlite.ts
  • test/integration/test/rewriting-middleware.integration.test.ts
  • test/integration/test/utils.ts

Comment on lines +275 to +294
```typescript
import { BinaryExpr, ColumnRef, LiteralExpr } from '@prisma-next/sql-relational-core/ast'
import type { SqlMiddleware } from '@prisma-next/sql-runtime'

export const softDelete: SqlMiddleware = {
name: 'softDelete',
familyId: 'sql',
async beforeCompile(draft) {
if (draft.ast.kind !== 'select') return
const notDeleted = BinaryExpr.eq(
ColumnRef.of('users', 'deleted_at'),
LiteralExpr.of(null),
)
const nextWhere = draft.ast.where
? AndExpr.of([draft.ast.where, notDeleted])
: notDeleted
return { ...draft, ast: draft.ast.withWhere(nextWhere) }
},
}
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

AndExpr is used in the example but not imported.

The soft-delete example references AndExpr.of([draft.ast.where, notDeleted]) on Line 289 but the import statement on Line 276 only pulls in BinaryExpr, ColumnRef, and LiteralExpr. Readers copy-pasting this snippet will hit an undefined-name error.

📝 Proposed fix
-import { BinaryExpr, ColumnRef, LiteralExpr } from '@prisma-next/sql-relational-core/ast'
+import { AndExpr, BinaryExpr, ColumnRef, LiteralExpr } from '@prisma-next/sql-relational-core/ast'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/architecture` docs/subsystems/4. Runtime & Middleware Framework.md
around lines 275 - 294, The example middleware softDelete uses AndExpr but the
import list only includes BinaryExpr, ColumnRef, and LiteralExpr; update the
import statement to also import AndExpr from
'@prisma-next/sql-relational-core/ast' so AndExpr.of(...) is defined for the
beforeCompile function in the softDelete middleware.

@SevInf SevInf enabled auto-merge (squash) April 27, 2026 07:50
@SevInf SevInf disabled auto-merge April 27, 2026 07:50
@SevInf SevInf merged commit 815feb3 into main Apr 27, 2026
16 checks passed
@SevInf SevInf deleted the writeable-middleware branch April 27, 2026 07:50
wmadden added a commit that referenced this pull request Apr 27, 2026
Decision: introduce an abstract RuntimeCore base class in
@prisma-next/framework-components/runtime that owns the full middleware
lifecycle (runBeforeCompile -> lower -> runWithMiddleware). SqlRuntime and
MongoRuntime extend it, providing two abstract overrides each (lower,
runDriver) plus an optional runBeforeCompile override. The duplicated
middleware orchestrator collapses into a single runWithMiddleware helper.

QueryPlan<Row> and ExecutionPlan<Row> become content-free nominal markers
in framework-components; the SQL-shaped ExecutionPlan leaves
@prisma-next/contract/types and lives in the SQL domain as
SqlExecutionPlan. Mongo gains a typed MongoExecutionPlan wrapping the
inline wire command.

Plan: five milestones (markers + ExecutionPlan relocation; abstract base
+ helper; SQL migration; Mongo migration; SQL ORM RuntimeQueryable
reconciliation). Project branch is rooted on PR #373
(origin/writeable-middleware) so M3 lifts the existing beforeCompile chain
rather than reinventing it; PRs target writeable-middleware until #373
merges, then re-target to main.

Linear: TML-2242.
wmadden added a commit that referenced this pull request Apr 27, 2026
Decision: introduce an abstract RuntimeCore base class in
@prisma-next/framework-components/runtime that owns the full middleware
lifecycle (runBeforeCompile -> lower -> runWithMiddleware). SqlRuntime and
MongoRuntime extend it, providing two abstract overrides each (lower,
runDriver) plus an optional runBeforeCompile override. The duplicated
middleware orchestrator collapses into a single runWithMiddleware helper.

QueryPlan<Row> and ExecutionPlan<Row> become content-free nominal markers
in framework-components; the SQL-shaped ExecutionPlan leaves
@prisma-next/contract/types and lives in the SQL domain as
SqlExecutionPlan. Mongo gains a typed MongoExecutionPlan wrapping the
inline wire command.

Plan: five milestones (markers + ExecutionPlan relocation; abstract base
+ helper; SQL migration; Mongo migration; SQL ORM RuntimeQueryable
reconciliation). Project branch is rooted on PR #373
(origin/writeable-middleware) so M3 lifts the existing beforeCompile chain
rather than reinventing it; PRs target writeable-middleware until #373
merges, then re-target to main.

Linear: TML-2242.
wmadden added a commit that referenced this pull request Apr 27, 2026
Decision: introduce an abstract RuntimeCore base class in
@prisma-next/framework-components/runtime that owns the full middleware
lifecycle (runBeforeCompile -> lower -> runWithMiddleware). SqlRuntime and
MongoRuntime extend it, providing two abstract overrides each (lower,
runDriver) plus an optional runBeforeCompile override. The duplicated
middleware orchestrator collapses into a single runWithMiddleware helper.

QueryPlan<Row> and ExecutionPlan<Row> become content-free nominal markers
in framework-components; the SQL-shaped ExecutionPlan leaves
@prisma-next/contract/types and lives in the SQL domain as
SqlExecutionPlan. Mongo gains a typed MongoExecutionPlan wrapping the
inline wire command.

Plan: five milestones (markers + ExecutionPlan relocation; abstract base
+ helper; SQL migration; Mongo migration; SQL ORM RuntimeQueryable
reconciliation). Project branch is rooted on PR #373
(origin/writeable-middleware) so M3 lifts the existing beforeCompile chain
rather than reinventing it; PRs target writeable-middleware until #373
merges, then re-target to main.

Linear: TML-2242.
wmadden added a commit that referenced this pull request Apr 28, 2026
Decision: introduce an abstract RuntimeCore base class in
@prisma-next/framework-components/runtime that owns the full middleware
lifecycle (runBeforeCompile -> lower -> runWithMiddleware). SqlRuntime and
MongoRuntime extend it, providing two abstract overrides each (lower,
runDriver) plus an optional runBeforeCompile override. The duplicated
middleware orchestrator collapses into a single runWithMiddleware helper.

QueryPlan<Row> and ExecutionPlan<Row> become content-free nominal markers
in framework-components; the SQL-shaped ExecutionPlan leaves
@prisma-next/contract/types and lives in the SQL domain as
SqlExecutionPlan. Mongo gains a typed MongoExecutionPlan wrapping the
inline wire command.

Plan: five milestones (markers + ExecutionPlan relocation; abstract base
+ helper; SQL migration; Mongo migration; SQL ORM RuntimeQueryable
reconciliation). Project branch is rooted on PR #373
(origin/writeable-middleware) so M3 lifts the existing beforeCompile chain
rather than reinventing it; PRs target writeable-middleware until #373
merges, then re-target to main.

Linear: TML-2242.
SevInf added a commit that referenced this pull request Apr 30, 2026
closes
[TML-2311](https://linear.app/prisma-company/issue/TML-2311/pn-execution-metadata-lives-on-ast)

## Intent

Make the SQL AST the single source of truth for execution metadata.
Today two channels carry it: the AST itself, and a sidecar bag on
`PlanMeta` (`refs`, `paramDescriptors`, `annotations.codecs`,
`projectionTypes`). Both builder paths populate the sidecar by walking
the AST and flattening per-node info into per-alias and per-index maps;
the runtime then chooses inconsistently which channel to read. With the
`beforeCompile` middleware hook now in main (PR #373), AST rewrites
silently invalidate every alias-keyed or index-keyed sidecar entry, and
the recently merged `re-derive paramDescriptors after middleware AST
rewrite` workaround lives exactly to paper over that drift.

ADR 205 collapses the two channels into one. `ProjectionItem` carries
the projection's output codec; `ParamRef` already carries its parameter
codec. The decoder builds its alias→codec lookup by walking the AST; the
encoder reads from `ParamRef.codecId`. `PlanMeta` shrinks to
identification + policy fields. Raw plans — which have no AST — pass
parameters and rows through to the driver and back, consistent with the
escape-hatch contract from ADR 012.

## Change map

- **AST + contract types** —
`packages/1-framework/0-foundation/contract/src/types.ts`,
`packages/2-sql/4-lanes/relational-core/src/ast/types.ts`,
`packages/2-sql/4-lanes/relational-core/src/types.ts`.
`ProjectionItem.codecId` is now optional.
`Insert/Update/Delete.returning` is `ProjectionItem[]`. Mutation ASTs
gain `rewrite()` methods that descend into `returning`. `PlanMeta` loses
`paramDescriptors`/`refs`/`projection`/`projectionTypes`/`annotations.codecs`.
`ParamDescriptor` and `PlanRefs` are deleted from the contract package.
`RawTemplateOptions` loses `refs` and `projection`. The dead AST
traversal helpers (`collectRefs` on `QueryAst`/`FromSource`/all
subclasses, plus `mergeRefsInto`/`addColumnRefToRefSets`/`sortRefs`) are
removed too.
- **Producers** —
`packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts`,
`packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts`,
`packages/3-extensions/sql-orm-client/src/query-plan-{meta,select,mutations,aggregate}.ts`,
`packages/3-extensions/sql-orm-client/src/where-binding.ts`. Every
emitted `ProjectionItem` is now stamped with the codec ID the producer
already had in scope (column lookup for SELECTs and RETURNINGs,
scope-field lookup for the DSL builder). Both producers stop emitting
the four sidecar fields.
- **Runtime** —
`packages/2-sql/5-runtime/src/codecs/{decoding,encoding}.ts`,
`packages/2-sql/5-runtime/src/middleware/{before-compile-chain,budgets}.ts`,
`packages/2-sql/5-runtime/src/guardrails/raw.ts`. The decoder walks the
AST's projection/returning list to build its alias→codec lookup; the
encoder reads `codecId` off each `ParamRef` collected from the AST.
`runBeforeCompileChain` no longer re-derives `paramDescriptors`. The
budgets middleware derives the primary table from `ast.from` instead of
`meta.refs.tables[0]`; the raw heuristic collapses to a SQL-text LIMIT
regex. Raw guardrails drop the refs-based index-coverage path.
- **Adapter** —
`packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts`,
`packages/3-targets/6-adapters/sqlite/src/core/adapter.ts`. Postgres and
SQLite render RETURNING from `ProjectionItem[]` with alias-aware
emission: `<table>.<column>` when alias matches column,
`<table>.<column> AS <alias>` when it differs, `<expr> AS <alias>` for
non-`ColumnRef` projections.
- **Cross-package fixture cleanup** — Mongo
query-builder/ORM/state-classes producers and the Mongo plan-JSON
serializer drop `paramDescriptors`. Test fixtures across
framework-components, middleware-telemetry, mongo-family, mongo-target,
and cross-package integration tests are updated to the narrowed
`PlanMeta` shape.
- **Docs + project workspace** — `docs/architecture docs/adrs/ADR 012 -
Raw SQL Escape Hatch.md` carries a banner marking its optional
structured-annotations branch retired by ADR 205. The driving spec +
plan ship under `projects/execution-metadata-on-ast/`.

## The story

The change starts at the AST. `ProjectionItem` previously carried only
`(alias, expr)`; ADR 205 makes its codec ID a first-class field on the
node. The mutation `returning` lists were `ColumnRef[]` — no alias, no
codec — and now they are `ProjectionItem[]`, putting RETURNING outputs
on the same footing as SELECT projections. To keep `beforeCompile`
rewrites correct-by-construction, the mutation ASTs gain `rewrite()`
methods that descend into each `ProjectionItem.expr` of `returning`,
mirroring `SelectAst.projection`'s long-standing rewrite path.

Once the AST can carry the metadata, the producers stop emitting the
sidecar. `buildOrmQueryPlan` previously called
`resolveProjectionCodecs(contract, ast)` to flatten projections into a
`projectionTypes` map; that work moves up to the construction sites
where the codec is already in hand (`buildProjection`,
`buildReturningColumns`, the aggregate-projection builder, the DSL
`select(...)` resolver). `buildQueryPlan` in the SQL builder lane
likewise stamps codecs at projection-construction time and stops walking
the row-fields map for sidecar purposes. `where-binding`'s
`bindSelectAst` preserves `codecId` when it rebuilds projections so the
rebound AST stays equivalent.

The runtime then collapses two consumers to one source. The decoder used
to read `meta.annotations.codecs` first, then fall back to
`meta.projectionTypes`, then build a column-ref index from
`meta.refs.columns` for error messages and JSON-Schema validator keys.
After this PR it walks the AST's projection (or returning) list once per
plan, builds an alias→codec lookup and an alias→`{table, column}` map
from `ProjectionItem.expr` when the expression is a `ColumnRef`, and
recognises subquery / json-array-agg projections as include aggregates
(replacing the old `include:`-prefixed string convention in
`meta.projection`). For raw plans — no AST — the decoder hands wire
values straight back. The encoder follows the same pattern: ordered,
deduplicated `ast.collectParamRefs()` produces metadata aligned with
`plan.params`, raw plans pass values through.

`runBeforeCompileChain` had a comment-block workaround explaining that
AST rewrites invalidate `paramDescriptors` and that we re-derive them.
With descriptors gone, that whole workaround is gone — the AST is what
the encoder reads, so middleware that introduces ParamRefs cannot drift.

The lints and budgets middleware lose their `refs`-driven paths.
`evaluateAstLints` already covered the AST-backed cases; raw plans now
get only the SQL-string heuristics ADR 205 expressly degrades to. The
budgets middleware reads the primary table off `ast.from`; the raw
fallback collapses to a LIMIT-regex check rather than chasing
`meta.refs.tables[0]`. The Postgres and SQLite renderers swap their
`RETURNING <ColumnRef>[]` lowering for `ProjectionItem[]`-aware
rendering, emitting `AS <alias>` only when the alias diverges from the
column name to keep the snapshot diff minimal.

Test fixtures that hand-constructed plans with `meta: {
paramDescriptors: [...], projectionTypes: {...}, refs: {...} }` migrate
to either pass an AST holding the equivalent `ParamRef.codecId` /
`ProjectionItem.codecId`, or — where they were exercising raw-plan
encoding — drop the codec-driven assertion since raw plans no longer
codec-encode.

## Behavior changes & evidence

- **Decoded values, encoded parameters, lints, and budget evaluations**
for AST-backed plans are unchanged. The decoder finds the same codec for
the same alias; the encoder finds the same codec for the same param.
([codecs/decoding.ts](packages/2-sql/5-runtime/src/codecs/decoding.ts),
[codecs/encoding.ts](packages/2-sql/5-runtime/src/codecs/encoding.ts),
[middleware/budgets.ts](packages/2-sql/5-runtime/src/middleware/budgets.ts))
- **RETURNING SQL text changes only when an alias differs from the
underlying column name** — the `AS <alias>` suffix is now emitted in
that case. The alias-matches-column case is byte-identical to the old
output.
([postgres/src/core/sql-renderer.ts](packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts),
[sqlite/src/core/adapter.ts](packages/3-targets/6-adapters/sqlite/src/core/adapter.ts))
- **Raw plans drop per-parameter codec encoding and per-alias codec
decoding.** Index-coverage lints and the `refs.tables[0]` row-count
heuristic that depended on raw-plan annotations are removed;
`select-star`, `missing LIMIT`, and `mutation-without-WHERE` SQL-string
heuristics still run on raw plans.
([guardrails/raw.ts](packages/2-sql/5-runtime/src/guardrails/raw.ts))
- **`beforeCompile` AST rewrites that introduce, swap, or remove
ParamRefs no longer require any sidecar maintenance.** A new test in
`before-compile-chain.test.ts` asserts that a `beforeCompile` rewriter
that swaps a projection alias decodes correctly end-to-end through the
AST-driven decoder.
([before-compile-chain.test.ts](packages/2-sql/5-runtime/test/before-compile-chain.test.ts))

## Compatibility / migration / risk

- **No backwards-compatibility shims.** Per repo policy, every in-repo
call site that constructed a plan with the removed sidecars is updated
at the point of change. `ParamDescriptor` and `PlanRefs` are deleted
outright, not deprecated.
- **Plan identity is unaffected.** ADR 013 already excluded the removed
fields from identity hashing.
- **Telemetry is unaffected.** Timing, row counts, and error codes flow
through paths independent of the removed sidecars.
- **Mongo family changes are limited to dropping `paramDescriptors` from
PlanMeta call sites.** Cross-family invariant ("AST is the source when
one exists") applies family-wide; specific Mongo-family migration of
ProjectionItem-style metadata is separate work.

## Follow-ups / open questions

- The `projects/execution-metadata-on-ast/` workspace ships with the PR
for reviewability; it can be deleted in a follow-up close-out commit.
ADR 205 is canonical under `docs/architecture docs/adrs/`.
- `ProjectionItem.codecId` is optional. Once all in-repo producers stamp
it (this PR), it's a candidate for promotion to required in a future
change. Out of scope here.

## Non-goals / intentionally out of scope

- Restoring the unindexed-predicate lint or the refs-based row-count
budget for raw plans. ADR 205 accepts this as a known degradation.
- New lints on top of the AST. Lint coverage stays at parity (or
strictly degrades for raw plans only).
- The ADR 012 minimal annotation schema (`intent`, `isMutation`,
`hasWhere`, `hasLimit`). Unchanged.
- Cross-family Mongo migration of execution metadata onto the Mongo AST.
Tracked separately.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Execution metadata (codec IDs, param info, projection mapping) is now
carried on AST nodes, not plan metadata.

* **Behavior Changes**
* Raw SQL plans forward caller parameters and return wire rows unchanged
(no automatic codec encoding/decoding).
* Linting and row-budget heuristics now run only for AST-backed plans;
raw plans use existing SQL-text heuristics.

* **Documentation**
* ADR and architecture notes updated to reflect the new metadata and
behavioral expectations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
wmadden added a commit that referenced this pull request May 4, 2026
Decision: introduce an abstract RuntimeCore base class in
@prisma-next/framework-components/runtime that owns the full middleware
lifecycle (runBeforeCompile -> lower -> runWithMiddleware). SqlRuntime and
MongoRuntime extend it, providing two abstract overrides each (lower,
runDriver) plus an optional runBeforeCompile override. The duplicated
middleware orchestrator collapses into a single runWithMiddleware helper.

QueryPlan<Row> and ExecutionPlan<Row> become content-free nominal markers
in framework-components; the SQL-shaped ExecutionPlan leaves
@prisma-next/contract/types and lives in the SQL domain as
SqlExecutionPlan. Mongo gains a typed MongoExecutionPlan wrapping the
inline wire command.

Plan: five milestones (markers + ExecutionPlan relocation; abstract base
+ helper; SQL migration; Mongo migration; SQL ORM RuntimeQueryable
reconciliation). Project branch is rooted on PR #373
(origin/writeable-middleware) so M3 lifts the existing beforeCompile chain
rather than reinventing it; PRs target writeable-middleware until #373
merges, then re-target to main.

Linear: TML-2242.
wmadden pushed a commit that referenced this pull request May 4, 2026
closes
[TML-2311](https://linear.app/prisma-company/issue/TML-2311/pn-execution-metadata-lives-on-ast)

## Intent

Make the SQL AST the single source of truth for execution metadata.
Today two channels carry it: the AST itself, and a sidecar bag on
`PlanMeta` (`refs`, `paramDescriptors`, `annotations.codecs`,
`projectionTypes`). Both builder paths populate the sidecar by walking
the AST and flattening per-node info into per-alias and per-index maps;
the runtime then chooses inconsistently which channel to read. With the
`beforeCompile` middleware hook now in main (PR #373), AST rewrites
silently invalidate every alias-keyed or index-keyed sidecar entry, and
the recently merged `re-derive paramDescriptors after middleware AST
rewrite` workaround lives exactly to paper over that drift.

ADR 205 collapses the two channels into one. `ProjectionItem` carries
the projection's output codec; `ParamRef` already carries its parameter
codec. The decoder builds its alias→codec lookup by walking the AST; the
encoder reads from `ParamRef.codecId`. `PlanMeta` shrinks to
identification + policy fields. Raw plans — which have no AST — pass
parameters and rows through to the driver and back, consistent with the
escape-hatch contract from ADR 012.

## Change map

- **AST + contract types** —
`packages/1-framework/0-foundation/contract/src/types.ts`,
`packages/2-sql/4-lanes/relational-core/src/ast/types.ts`,
`packages/2-sql/4-lanes/relational-core/src/types.ts`.
`ProjectionItem.codecId` is now optional.
`Insert/Update/Delete.returning` is `ProjectionItem[]`. Mutation ASTs
gain `rewrite()` methods that descend into `returning`. `PlanMeta` loses
`paramDescriptors`/`refs`/`projection`/`projectionTypes`/`annotations.codecs`.
`ParamDescriptor` and `PlanRefs` are deleted from the contract package.
`RawTemplateOptions` loses `refs` and `projection`. The dead AST
traversal helpers (`collectRefs` on `QueryAst`/`FromSource`/all
subclasses, plus `mergeRefsInto`/`addColumnRefToRefSets`/`sortRefs`) are
removed too.
- **Producers** —
`packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts`,
`packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts`,
`packages/3-extensions/sql-orm-client/src/query-plan-{meta,select,mutations,aggregate}.ts`,
`packages/3-extensions/sql-orm-client/src/where-binding.ts`. Every
emitted `ProjectionItem` is now stamped with the codec ID the producer
already had in scope (column lookup for SELECTs and RETURNINGs,
scope-field lookup for the DSL builder). Both producers stop emitting
the four sidecar fields.
- **Runtime** —
`packages/2-sql/5-runtime/src/codecs/{decoding,encoding}.ts`,
`packages/2-sql/5-runtime/src/middleware/{before-compile-chain,budgets}.ts`,
`packages/2-sql/5-runtime/src/guardrails/raw.ts`. The decoder walks the
AST's projection/returning list to build its alias→codec lookup; the
encoder reads `codecId` off each `ParamRef` collected from the AST.
`runBeforeCompileChain` no longer re-derives `paramDescriptors`. The
budgets middleware derives the primary table from `ast.from` instead of
`meta.refs.tables[0]`; the raw heuristic collapses to a SQL-text LIMIT
regex. Raw guardrails drop the refs-based index-coverage path.
- **Adapter** —
`packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts`,
`packages/3-targets/6-adapters/sqlite/src/core/adapter.ts`. Postgres and
SQLite render RETURNING from `ProjectionItem[]` with alias-aware
emission: `<table>.<column>` when alias matches column,
`<table>.<column> AS <alias>` when it differs, `<expr> AS <alias>` for
non-`ColumnRef` projections.
- **Cross-package fixture cleanup** — Mongo
query-builder/ORM/state-classes producers and the Mongo plan-JSON
serializer drop `paramDescriptors`. Test fixtures across
framework-components, middleware-telemetry, mongo-family, mongo-target,
and cross-package integration tests are updated to the narrowed
`PlanMeta` shape.
- **Docs + project workspace** — `docs/architecture docs/adrs/ADR 012 -
Raw SQL Escape Hatch.md` carries a banner marking its optional
structured-annotations branch retired by ADR 205. The driving spec +
plan ship under `projects/execution-metadata-on-ast/`.

## The story

The change starts at the AST. `ProjectionItem` previously carried only
`(alias, expr)`; ADR 205 makes its codec ID a first-class field on the
node. The mutation `returning` lists were `ColumnRef[]` — no alias, no
codec — and now they are `ProjectionItem[]`, putting RETURNING outputs
on the same footing as SELECT projections. To keep `beforeCompile`
rewrites correct-by-construction, the mutation ASTs gain `rewrite()`
methods that descend into each `ProjectionItem.expr` of `returning`,
mirroring `SelectAst.projection`'s long-standing rewrite path.

Once the AST can carry the metadata, the producers stop emitting the
sidecar. `buildOrmQueryPlan` previously called
`resolveProjectionCodecs(contract, ast)` to flatten projections into a
`projectionTypes` map; that work moves up to the construction sites
where the codec is already in hand (`buildProjection`,
`buildReturningColumns`, the aggregate-projection builder, the DSL
`select(...)` resolver). `buildQueryPlan` in the SQL builder lane
likewise stamps codecs at projection-construction time and stops walking
the row-fields map for sidecar purposes. `where-binding`'s
`bindSelectAst` preserves `codecId` when it rebuilds projections so the
rebound AST stays equivalent.

The runtime then collapses two consumers to one source. The decoder used
to read `meta.annotations.codecs` first, then fall back to
`meta.projectionTypes`, then build a column-ref index from
`meta.refs.columns` for error messages and JSON-Schema validator keys.
After this PR it walks the AST's projection (or returning) list once per
plan, builds an alias→codec lookup and an alias→`{table, column}` map
from `ProjectionItem.expr` when the expression is a `ColumnRef`, and
recognises subquery / json-array-agg projections as include aggregates
(replacing the old `include:`-prefixed string convention in
`meta.projection`). For raw plans — no AST — the decoder hands wire
values straight back. The encoder follows the same pattern: ordered,
deduplicated `ast.collectParamRefs()` produces metadata aligned with
`plan.params`, raw plans pass values through.

`runBeforeCompileChain` had a comment-block workaround explaining that
AST rewrites invalidate `paramDescriptors` and that we re-derive them.
With descriptors gone, that whole workaround is gone — the AST is what
the encoder reads, so middleware that introduces ParamRefs cannot drift.

The lints and budgets middleware lose their `refs`-driven paths.
`evaluateAstLints` already covered the AST-backed cases; raw plans now
get only the SQL-string heuristics ADR 205 expressly degrades to. The
budgets middleware reads the primary table off `ast.from`; the raw
fallback collapses to a LIMIT-regex check rather than chasing
`meta.refs.tables[0]`. The Postgres and SQLite renderers swap their
`RETURNING <ColumnRef>[]` lowering for `ProjectionItem[]`-aware
rendering, emitting `AS <alias>` only when the alias diverges from the
column name to keep the snapshot diff minimal.

Test fixtures that hand-constructed plans with `meta: {
paramDescriptors: [...], projectionTypes: {...}, refs: {...} }` migrate
to either pass an AST holding the equivalent `ParamRef.codecId` /
`ProjectionItem.codecId`, or — where they were exercising raw-plan
encoding — drop the codec-driven assertion since raw plans no longer
codec-encode.

## Behavior changes & evidence

- **Decoded values, encoded parameters, lints, and budget evaluations**
for AST-backed plans are unchanged. The decoder finds the same codec for
the same alias; the encoder finds the same codec for the same param.
([codecs/decoding.ts](packages/2-sql/5-runtime/src/codecs/decoding.ts),
[codecs/encoding.ts](packages/2-sql/5-runtime/src/codecs/encoding.ts),
[middleware/budgets.ts](packages/2-sql/5-runtime/src/middleware/budgets.ts))
- **RETURNING SQL text changes only when an alias differs from the
underlying column name** — the `AS <alias>` suffix is now emitted in
that case. The alias-matches-column case is byte-identical to the old
output.
([postgres/src/core/sql-renderer.ts](packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts),
[sqlite/src/core/adapter.ts](packages/3-targets/6-adapters/sqlite/src/core/adapter.ts))
- **Raw plans drop per-parameter codec encoding and per-alias codec
decoding.** Index-coverage lints and the `refs.tables[0]` row-count
heuristic that depended on raw-plan annotations are removed;
`select-star`, `missing LIMIT`, and `mutation-without-WHERE` SQL-string
heuristics still run on raw plans.
([guardrails/raw.ts](packages/2-sql/5-runtime/src/guardrails/raw.ts))
- **`beforeCompile` AST rewrites that introduce, swap, or remove
ParamRefs no longer require any sidecar maintenance.** A new test in
`before-compile-chain.test.ts` asserts that a `beforeCompile` rewriter
that swaps a projection alias decodes correctly end-to-end through the
AST-driven decoder.
([before-compile-chain.test.ts](packages/2-sql/5-runtime/test/before-compile-chain.test.ts))

## Compatibility / migration / risk

- **No backwards-compatibility shims.** Per repo policy, every in-repo
call site that constructed a plan with the removed sidecars is updated
at the point of change. `ParamDescriptor` and `PlanRefs` are deleted
outright, not deprecated.
- **Plan identity is unaffected.** ADR 013 already excluded the removed
fields from identity hashing.
- **Telemetry is unaffected.** Timing, row counts, and error codes flow
through paths independent of the removed sidecars.
- **Mongo family changes are limited to dropping `paramDescriptors` from
PlanMeta call sites.** Cross-family invariant ("AST is the source when
one exists") applies family-wide; specific Mongo-family migration of
ProjectionItem-style metadata is separate work.

## Follow-ups / open questions

- The `projects/execution-metadata-on-ast/` workspace ships with the PR
for reviewability; it can be deleted in a follow-up close-out commit.
ADR 205 is canonical under `docs/architecture docs/adrs/`.
- `ProjectionItem.codecId` is optional. Once all in-repo producers stamp
it (this PR), it's a candidate for promotion to required in a future
change. Out of scope here.

## Non-goals / intentionally out of scope

- Restoring the unindexed-predicate lint or the refs-based row-count
budget for raw plans. ADR 205 accepts this as a known degradation.
- New lints on top of the AST. Lint coverage stays at parity (or
strictly degrades for raw plans only).
- The ADR 012 minimal annotation schema (`intent`, `isMutation`,
`hasWhere`, `hasLimit`). Unchanged.
- Cross-family Mongo migration of execution metadata onto the Mongo AST.
Tracked separately.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Execution metadata (codec IDs, param info, projection mapping) is now
carried on AST nodes, not plan metadata.

* **Behavior Changes**
* Raw SQL plans forward caller parameters and return wire rows unchanged
(no automatic codec encoding/decoding).
* Linting and row-budget heuristics now run only for AST-backed plans;
raw plans use existing SQL-text heuristics.

* **Documentation**
* ADR and architecture notes updated to reflect the new metadata and
behavioral expectations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

1 participant