refactor(sql): share transaction-wrapping and error-formatting across transports#29
Conversation
… transports The RPC ExecuteSQL handler and the MCP execution tools each had their own copy of the multi-statement transaction-wrapping logic (identical code + comment), and the PostgreSQL error enrichment (line context, DETAIL, HINT) existed only on the RPC path — so MCP agents got bare error messages. Extract both into server/lib/execute-sql.ts: - buildExecutableSql(rawSql, analysis) — the BEGIN/COMMIT wrapping - formatExecutionError(err, sql) — line/DETAIL/HINT enrichment Both transports now call the shared helpers. This removes the copy-pasted wrapping (single source of truth) and gives MCP agents the same rich errors the UI shows, while leaving the intentional differences (permission ladders, result shaping, streaming/PID) in each handler. Audit still records the bare error message on both paths; only the surfaced/returned error is enriched. Tests: new tests/execute-sql.test.ts (8) + existing mcp (32) pass; tsc clean; server builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR extracts shared SQL execution helpers so both the ConnectRPC ExecuteSQL handler and the MCP execution tools use the same transaction-wrapping behavior and the same enriched Postgres error formatting, reducing drift between transports.
Changes:
- Added
server/lib/execute-sql.tswithbuildExecutableSql(BEGIN/COMMIT wrapping) andformatExecutionError(line/DETAIL/HINT enrichment). - Updated
server/services/query-service.tsandserver/mcp.tsto route through the shared helpers. - Added
tests/execute-sql.test.tscovering wrapping behavior and error formatting.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| tests/execute-sql.test.ts | Adds unit coverage for shared SQL wrapping + error formatting helpers. |
| server/services/query-service.ts | Replaces inlined wrapping/error-enrichment with shared helper calls in the RPC handler. |
| server/mcp.ts | Reuses shared wrapping and adds enriched error formatting to MCP execution errors. |
| server/lib/execute-sql.ts | Introduces transport-agnostic helpers for executable SQL construction and error formatting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| auditSQL(actor, connection, details.database, auditSql, false, Date.now() - start, undefined, message, opts) | ||
| throw new Error(message) | ||
| // Surface the same line/DETAIL/HINT context the UI gets; audit keeps the bare message. | ||
| throw new Error(formatExecutionError(err, auditSql)) |
There was a problem hiding this comment.
Fixed in d20735e — both call sites now format against the executed SQL (RPC: executableSql hoisted above the try; MCP: execSql), so the Postgres position maps to the right line. Added a regression test for the transaction-wrapped case. Good catch — and you're right this also closes the pre-existing RPC mismatch, not just the one MCP newly introduced.
| if (typeof pgErr?.hint === 'string' && pgErr.hint) { | ||
| fullError += `\nHINT: ${pgErr.hint}`; | ||
| } | ||
| const fullError = formatExecutionError(err, req.sql); |
There was a problem hiding this comment.
Fixed in d20735e — both call sites now format against the executed SQL (RPC: executableSql hoisted above the try; MCP: execSql), so the Postgres position maps to the right line. Added a regression test for the transaction-wrapped case. Good catch — and you're right this also closes the pre-existing RPC mismatch, not just the one MCP newly introduced.
Greptile SummaryThis PR extracts two duplicated pieces of the SQL execution pipeline — transaction wrapping and error enrichment — into a new shared module (
Confidence Score: 4/5Safe to merge — the refactor preserves all existing execution behavior and meaningfully improves MCP error quality with no risk to correctness. Both transports now share helpers that are well-tested and faithful extractions of the original inline logic. The one noteworthy issue is that server/mcp.ts — specifically the Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant RPC as ConnectRPC (query-service.ts)
participant MCP as MCP Agent (mcp.ts)
participant LIB as execute-sql.ts
participant PG as PostgreSQL
RPC->>LIB: buildExecutableSql(req.sql, analysis)
MCP->>LIB: buildExecutableSql(rawSql, analysis)
LIB-->>RPC: wrapped or plain SQL
LIB-->>MCP: wrapped or plain SQL
RPC->>PG: client.unsafe(wrappedSql)
MCP->>PG: runStatement(wrappedSql)
alt success
PG-->>RPC: result rows
PG-->>MCP: result rows
else error
PG-->>RPC: pg error (with position)
RPC->>LIB: formatExecutionError(err, req.sql)
LIB-->>RPC: enriched error string
PG-->>MCP: pg error (with position)
MCP->>LIB: formatExecutionError(err, auditSql)
LIB-->>MCP: enriched error string
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant RPC as ConnectRPC (query-service.ts)
participant MCP as MCP Agent (mcp.ts)
participant LIB as execute-sql.ts
participant PG as PostgreSQL
RPC->>LIB: buildExecutableSql(req.sql, analysis)
MCP->>LIB: buildExecutableSql(rawSql, analysis)
LIB-->>RPC: wrapped or plain SQL
LIB-->>MCP: wrapped or plain SQL
RPC->>PG: client.unsafe(wrappedSql)
MCP->>PG: runStatement(wrappedSql)
alt success
PG-->>RPC: result rows
PG-->>MCP: result rows
else error
PG-->>RPC: pg error (with position)
RPC->>LIB: formatExecutionError(err, req.sql)
LIB-->>RPC: enriched error string
PG-->>MCP: pg error (with position)
MCP->>LIB: formatExecutionError(err, auditSql)
LIB-->>MCP: enriched error string
end
Reviews (1): Last reviewed commit: "refactor(sql): share transaction-wrappin..." | Re-trigger Greptile |
| auditSQL(actor, connection, details.database, auditSql, false, Date.now() - start, undefined, message, opts) | ||
| throw new Error(message) | ||
| // Surface the same line/DETAIL/HINT context the UI gets; audit keeps the bare message. | ||
| throw new Error(formatExecutionError(err, auditSql)) |
There was a problem hiding this comment.
Postgres position offset against wrong SQL string
formatExecutionError is documented as "sql is the statement the position refers to," but auditSql (= rawSql) is passed while execSql (= finalSql) is the string actually sent to Postgres. For a wrapped multi-statement batch, execSql is BEGIN;\n${rawSql}\n;\nCOMMIT;, so every error position reported by Postgres is shifted by 7 characters ("BEGIN;\n"). The line-extraction logic will then map that position onto rawSql, quietly landing on the wrong line whenever the error falls in the first several lines of a multi-statement transaction. query-service.ts carries the same pre-existing mismatch; this PR introduces it to the MCP path for the first time.
There was a problem hiding this comment.
Fixed in d20735e — both call sites now format against the executed SQL (RPC: executableSql hoisted above the try; MCP: execSql), so the Postgres position maps to the right line. Added a regression test for the transaction-wrapped case. Good catch — and you're right this also closes the pre-existing RPC mismatch, not just the one MCP newly introduced.
…rectly PR review (Copilot + Greptile): formatExecutionError uses the Postgres error `position` to compute line context, and `position` indexes into the string actually sent to the server. Both call sites passed the raw SQL, so for a transaction-wrapped batch (`BEGIN;\n...\n;\nCOMMIT;`) the offset was shifted and the reported line/offending-line could be wrong. Pass the executed SQL instead: - query-service: hoist `executableSql` above the try so the catch formats against it (fixes the pre-existing RPC mismatch). - mcp: format against `execSql` rather than `auditSql`. Add a regression test asserting position maps onto the wrapped SQL (BEGIN; is line 1, so user statements shift down by one). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // Build a richer error with line context, detail, and hint from PostgreSQL. Format against | ||
| // the executed SQL so the error `position` maps to the right line. | ||
| const fullError = formatExecutionError(err, executableSql); |
There was a problem hiding this comment.
Looked into this and I'm going to leave it as-is. Two reasons:
-
The suggested remedy isn't available. postgres.js builds its error solely from PG's
ErrorResponsefields (parseErrorin connection.js →Errors.postgres); it does not attach the failing SQL text to the error object. There's noerr.queryto prefer overexecutableSql. -
The edge is effectively unreachable and not a regression. The only other statements in this try are
SELECT pg_backend_pid()(a constant — cannot carry aposition) andSET search_path TO <escaped idents>(doesn't validate schema existence, won't produce a position-bearing error with quoted identifiers). The pre-existing code formatted againstreq.sql, which was equally unrelated to a setup-statement error — so this isn't introduced here.
Isolating the main query in its own try/catch to scope the formatting would be a real restructure of a streaming generator for a scenario that can't occur in practice, so per the repo's 'no error handling for impossible scenarios' guideline I'll skip it.
Why
This is the first increment of the single core, dual transport idea: the ConnectRPC
ExecuteSQLhandler and the MCP execution tools were each carrying their own copy of the execute pipeline, and the copies had already drifted.query-service.tsandmcp.tsboth had the sameBEGIN;\n…\n;\nCOMMIT;logic and the same comment. A change to transaction-safety rules had to be made in both or they'd silently diverge.DETAIL+HINT. MCP agents got bareerr.message, i.e. strictly worse errors than the UI for the same failure.What
Extract the two genuinely-identical pieces into
server/lib/execute-sql.tsand route both transports through them:query-service.ts::executeSQL— uses both helpers (behavior unchanged; it already enriched).mcp.ts::execute— usesbuildExecutableSql;runAndAuditnow throwsformatExecutionError(...), so agents get the same rich errors the UI shows. Audit still records the bare message on both paths (unchanged).Deliberately not unified: the intentional differences stay in each handler — the user vs. agent permission ladders, result/column-metadata shaping, and the RPC streaming/PID/cancellation lifecycle. Forcing those together would abstract things that aren't actually the same.
Verification
tests/execute-sql.test.ts— 8 tests (wrapping matrix + error formatting incl. line context, DETAIL/HINT, non-Error fallback)tests/mcp.test.ts— 32 passtsc --noEmitclean;pnpm build:serversucceedsNote
This is the safe, surgical slice. A fuller
runGovernedSqlcore (also folding in execution + audit) is constrained by the RPC streaming generator's client lifecycle and is left for a follow-up if desired.🤖 Generated with Claude Code