feat(sql-runtime): add beforeCompile middleware hook#373
Conversation
📝 WalkthroughWalkthroughThis change introduces a pre-lowering SQL query rewriting mechanism via a new optional Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 minutes Possibly related PRsNo additional related PRs identified in the provided search results beyond the current implementation. Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/middleware-telemetry
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/emitter
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/runtime-executor
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
…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.
…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.
15c9562 to
9485f29
Compare
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.
9485f29 to
b8cde9e
Compare
There was a problem hiding this comment.
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" butDraftPlanis a sibling top-level export ofSqlMiddlewareinpackages/2-sql/5-runtime/src/middleware/sql-middleware.ts, not a nested member. Consider rephrasing to e.g. "via the SQL runtime'sDraftPlantype" 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, butposts,comments,profiles, andarticles(and thevectorextension) are also created. IfsetupTestDatabaserequires these to match the fixture contract'sstorage.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.astis asserted but notresult === draft. The implementation guarantees the originaldraftobject (not the new one returned by the middleware) is propagated whenresult.ast === current.ast, so addingexpect(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.beforeCompileoverwritesrewriteA's WHERE rather than combining predicates.
rewriteAaddsusers.a = 1, thenrewriteBcallsdraft.ast.withWhere(...)directly, replacing the predicate set byrewriteA. The final lowered AST therefore only containsb = 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 viaAndExprlike the unit test inbefore-compile-chain.test.tsdoes; 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: threadTMiddlewarethrough the inner generator'sselfparameter.Now that
RuntimeCoreImplis generic overTMiddleware, the inner generator still typesselfasRuntimeCoreImpl<TContract, TDriver>(defaultingTMiddlewaretoMiddleware<TContract>). It's only safe today becauseTMiddlewareis used covariantly viareadonly 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
📒 Files selected for processing (19)
docs/architecture docs/subsystems/4. Runtime & Middleware Framework.mdexamples/prisma-next-demo/src/prisma-no-emit/runtime.tspackages/1-framework/1-core/framework-components/src/runtime-middleware.tspackages/1-framework/4-runtime/runtime-executor/src/middleware/types.tspackages/1-framework/4-runtime/runtime-executor/src/runtime-core.tspackages/2-sql/5-runtime/src/middleware/before-compile-chain.tspackages/2-sql/5-runtime/src/middleware/budgets.tspackages/2-sql/5-runtime/src/middleware/lints.tspackages/2-sql/5-runtime/src/middleware/sql-middleware.tspackages/2-sql/5-runtime/src/sql-runtime.tspackages/2-sql/5-runtime/test/before-compile-chain.test.tspackages/2-sql/5-runtime/test/budgets.test.tspackages/2-sql/5-runtime/test/lints.test.tspackages/2-sql/5-runtime/test/sql-runtime.test.tspackages/3-extensions/middleware-telemetry/src/telemetry-middleware.tspackages/3-extensions/postgres/src/runtime/postgres.tspackages/3-extensions/sqlite/src/runtime/sqlite.tstest/integration/test/rewriting-middleware.integration.test.tstest/integration/test/utils.ts
| ```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) } | ||
| }, | ||
| } | ||
| ``` |
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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 -->
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.
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 -->
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
SqlRuntimeImplbeforelowerSqlPlan(), and does not alterRuntimeCoreImpl's execute pipeline. The cross-familyMiddleware<TContract>interface gains a genericbeforeCompile?(draft: { ast: unknown; meta }, ctx)so future families can adopt the same pattern;SqlMiddlewarenarrows it toDraftPlan { ast: AnyQueryAst }.The story
Starting point: observation-only middleware —
beforeExecute,onRow,afterExecuteall returnedPromise<void>. Users needing filters like soft-delete had no supported path.SqlMiddleware gains
beforeCompile. TypedDraftPlan → DraftPlan | undefined. Middlewares run in registration order; each sees the predecessor's output. Returningundefined(or a draft whoseastreference equals the input's) passes through silently. A newastreference replaces the current draft and emits amiddleware.rewriteevent viactx.log.debugnaming 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-loweringSqlQueryPlanper ADR 016, andlowerSqlPlan()is invoked insideSqlRuntimeImpl.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.RuntimeCorewas parameterized withTMiddlewareand exposesmiddlewareandmiddlewareContextas readonly;SqlRuntimeImplreads from core rather than maintaining duplicate state.RuntimeOptions.middlewaretightened fromMiddleware<TContract>[]toSqlMiddleware[].RuntimeLoggained an optionaldebug?for rewrite tracing.Existing SqlMiddleware implementations (
lints,budgets,createTelemetryMiddleware) were typed asSqlMiddlewaredirectly, removing earlier casts and the genericMiddleware<TContract>indirection where it wasn't needed.Behavior changes & evidence
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.adapter.lower, registers two chained middlewares, asserts a single call that receives the post-rewrite AST.sql-runtime.test.ts.Compatibility / migration / risk
debug?onRuntimeLog, genericbeforeCompile?onMiddleware<TContract>. Both optional; existing implementations compile unchanged.RuntimeOptions.middlewareis nowreadonly SqlMiddleware[](wasMiddleware<TContract>[]). In-repo callers (postgres.ts,sqlite.ts, integration utils, demo) updated in the same PR. External callers passing generic middleware withoutfamilyIdstill satisfy the type (SqlMiddleware.familyIdis optional); callers settingfamilyId: 'mongo'would now fail typecheck — which is the intended behavior.SqlMiddleware.familyIdrelaxed from required'sql'to optional'sql'— compatible with cross-family middleware likecreateTelemetryMiddlewarethat omitfamilyId.RuntimeCoreImplexecute semantics, lane.build(), or the lowering path beyond chain integration.LiteralExpr.of(userInput)from untrusted input bypass parameterization; documented in the new subsystem section and TSDoc.Follow-ups / open questions
Middleware<TContract>, but Mongo would need its own draft shape and invocation site.Non-goals / intentionally out of scope
dependsOn/conflictsWithmiddleware ordering metadata — registration-array order is the sole source of truth.rewriteTrailannotation on the plan — logs are the audit trail; rejected to keep plan shape unchanged.runtimeError.ExecutionPlan.aston existing hooks —beforeExecute/onRow/afterExecutekeepplan.ast: unknownfor v1;beforeCompileis the new typed surface.Summary by CodeRabbit
Release Notes
New Features
beforeCompilehook with debug event tracking.Documentation
Tests