Repository navigation
fix(cli): return failure exit code for SQL batches - #116
Conversation
| .option("limit", { type: "number", default: 100, describe: "Max rows to return (0 for unlimited)" }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute multiple semicolon-separated statements sequentially" }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" }) | ||
| .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) |
There was a problem hiding this comment.
MEDIUM (confidence: high on the code path, asking about intent) — --dry-run has the same hole this PR closes for --batch, and the two now contradict each other in help output.
.option("batch", { … describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" })
.option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. …" })
The dry-run branch aggregates per-statement outcomes and then hands the whole thing to success():
// sql.ts:661-671
if (r.status === JobStatus.FAILED)
return { sql: stmt, status: "error", job_id: r.jobId, error: await formatQueryError(…, "EXPLAIN failed") }
…
success({ statements: results, count: statements.length }, { format, rowsKey: "statements" })success() ends with process.exitCode = EXIT_OK (src/output/index.ts:68), so a dry-run whose EXPLAIN failed prints status: "error" on stdout and exits 0 — exactly the "automation treats a partial failure as success" symptom in the PR description. Because the dry-run branch returns before the batch loop, cz-cli sql --batch --dry-run "…" also still exits 0 while the --batch help text on the line above now promises "exit non-zero if any fails".
Is this deliberately out of scope? If so it's worth saying in the PR body, since --batch and --dry-run are the two multi-statement modes an automation harness reaches for. The parallel fix is one line: set EXIT_BIZ_ERROR when any results entry has status === "error" before calling success() (note the order matters — success() overwrites process.exitCode, so it has to be set after, or success() has to stop asserting EXIT_OK on that path).
| logOperation("sql", { sql: stmt, ok: true, rows: rows.length, timeMs: Date.now() - t0 }) | ||
| } | ||
| } catch (err) { | ||
| process.exitCode = EXIT_BIZ_ERROR |
There was a problem hiding this comment.
LOW (confidence: high) — the batch branch now does two of error()'s three jobs and skips the third, which leaves the telemetry event for a failed batch without an error code.
} catch (err) {
process.exitCode = EXIT_BIZ_ERRORerror() (src/output/index.ts:132-135) renders the envelope, sets process.exitCode, and records process.lastError = message. Both new assignments here (this one and line 707) correctly avoid error() — it would print a single top-level error envelope instead of a per-statement line — but they also skip the lastError write, and that value is what both entry points use to label the telemetry event:
src/run-cli.ts:865-867:track(!process.exitCode, process.exitCode ? lastError ?? \exit_code=${process.exitCode}` : undefined)`src/execute.ts:108-116: samelastError ?? \exit_code=${exitCode}`` fallback
So a --batch run that fails a statement now reports success: false, error: "exit_code=1", where a failing single statement reports the real message. Net still better than the pre-PR success: true, but the code/message is already in hand two lines below (classifyExecError, and r.errorCode in the sibling branch) — setting it alongside the exit code in both branches would make the failure legible in telemetry too.
Unrelated to the above, and worth keeping: putting the assignment before the awaited formatQueryError/formatClassifiedError means the exit code survives a throw from the formatter (it calls resolveAccountDisplayName, which does I/O). Deliberate or not, that ordering is the right one.
Review verdictA. Upstream invasiveness — no issues found. The diff is two files, both under B. Clean fix, with one adjacent gap. The core change is the right shape, not a hole drilled around the problem: every other SQL failure path already sets the exit code because it routes through Two inline findings:
C. Regression risk. One intended behavioral change, plus the help string:
I did not run the test suite, so nothing above is a claim that tests pass. |
--dry-run reported per-statement errors but exited 0. Batch and dry-run failures now also record lastError so telemetry carries the error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
|
||
| // Per-statement failures are already rendered inline, so error() would print a | ||
| // second envelope; set the exit code and telemetry error it would have set. | ||
| function markStatementFailure(message: string) { |
There was a problem hiding this comment.
MEDIUM (confidence: medium) — this helper re-implements the body of error(), and makes process.lastError a process-global with two independent writers in two modules.
function markStatementFailure(message: string) {
process.exitCode = EXIT_BIZ_ERROR
;(process as unknown as Record<string, unknown>).lastError = message
}output/index.ts:134-135 is the same two statements:
process.exitCode = opts?.exitCode ?? EXIT_BIZ_ERROR
;(process as unknown as Record<string, unknown>).lastError = messagelastError is an undeclared slot on process reached only by casting. Its readers are run-cli.ts:865 and execute.ts:108; until now its only writer was error(), so the cast lived once, next to the readers' contract. Writing it from a command module means the shape of that channel is now asserted in two places, and the cast hides any divergence from the type system.
The smaller correct change is to export the pair from output/index.ts — e.g. export function markFailure(message: string, exitCode = EXIT_BIZ_ERROR) — have error() call it, and import it here. That keeps one writer and removes the as unknown as Record<string, unknown> cast from sql.ts.
Worth noting the two existing partial-failure precedents set the exit code only and never touch lastError (status.ts:43, status.ts:81, analytics-agent.ts:1621), so there are now three variants of "mark this run as failed" in the package.
| const failed = results.find((r) => r.status === "error") | ||
| if (failed) markStatementFailure(String(failed.error)) |
There was a problem hiding this comment.
MEDIUM (confidence: high that the behavior changes; asking about intent) — please confirm the --dry-run exit-code change is intended.
const failed = results.find((r) => r.status === "error")
if (failed) markStatementFailure(String(failed.error))The PR title, body, and validation notes are all about --batch: "When a statement fails in a multi-statement sql --batch command…", "Document the exit behavior in --batch help", "Added six network-boundary regression cases covering -e and -f". But this hunk also changes sql … --dry-run, a separate code path that returns at line 681 before batch is ever reached. cz-cli sql "SELECT 1; SELECT bad" --dry-run previously exited 0 with statements[1].status === "error" in the payload; it now exits 1. A caller doing cz-cli sql "…" --dry-run && deploy changes behavior.
It looks deliberate (the --dry-run describe string on line 873 was updated and three dry-run cases were added to the new test file), so this is a request to confirm rather than a bug report — but the description undersells the scope, and --dry-run is the flag most likely to be sitting in someone's && chain precisely because it used to be advisory.
Second, smaller point on the same two lines: this works only because it runs after success(), which sets process.exitCode = EXIT_OK unconditionally (output/index.ts:68). Moving it above the success() call on line 678 silently reverts the fix with no test-visible cause at this site. The equivalent code in analytics-agent.ts:1618-1621 carries that constraint in a comment:
// success() resets exitCode to EXIT_OK; override AFTER it so a partial
// failure surfaces as a non-zero exit for scripts.
if (failed > 0) process.exitCode = EXIT_BIZ_ERRORPer AGENTS.md ("Add comments for non-obvious constraints and surprising behavior"), the same note belongs here.
| const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements }) | ||
| if (isQueryResult(r) && r.status === JobStatus.FAILED) { | ||
| const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: await formatQueryError(r, ctx, argv.profile) }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) } | ||
| markStatementFailure(line.error.message) |
There was a problem hiding this comment.
LOW (confidence: high) — in a batch, each failure overwrites lastError, so telemetry reports the last failing statement; the dry-run site at line 679 uses results.find(...) and reports the first. Same flag family, opposite answer for "which error did this run hit".
markStatementFailure(line.error.message)Calling it per-statement also means three mutation sites for one piece of run state. The established shape for this in the package is to count failures in the loop and set the exit code once after it (analytics-agent.ts:1621: if (failed > 0) process.exitCode = EXIT_BIZ_ERROR). Here that would be a const failures: string[] = [] accumulated in both failure branches, then one post-loop if (failures.length > 0) markStatementFailure(failures[0]) — one site, and whichever of first/last you pick becomes an explicit choice that matches dry-run.
Not a correctness bug: the exit code is 1 either way, and the existing tests only assert the exit code, not lastError.
| // second envelope; set the exit code and telemetry error it would have set. | ||
| function markStatementFailure(message: string) { | ||
| process.exitCode = EXIT_BIZ_ERROR | ||
| ;(process as unknown as Record<string, unknown>).lastError = message |
There was a problem hiding this comment.
LOW (confidence: medium) — this newly routes raw engine error text from batch statements into telemetry, untruncated.
;(process as unknown as Record<string, unknown>).lastError = messagemessage here is formatQueryError(...) / formatClassifiedError(...) output, i.e. the gateway's own error string. execute.ts:108-115 and run-cli.ts:865-867 read lastError whenever exitCode is non-zero and pass it to trackCommand({ error }), which emits it verbatim as the cz_cli.command.error OTel attribute (telemetry.ts:220) with no length cap and no redaction.
Before this PR a batch partial failure exited 0, so telemetry recorded success: true and sent nothing; now it sends the message. That is parity with the single-statement path (handleFailure → error() already does the same), so it is not a new class of exposure — but engine errors frequently echo the offending statement, and .github/claude-review-context.md flags this repo for plaintext credentials having reached telemetry before. A statement like CREATE STORAGE CONNECTION … ACCESS_KEY='…' that fails inside a batch would now put that text in the span attribute.
If that is a concern, the fix is at the one shared writer rather than here (see the markFailure suggestion on line 507): cap the length and strip credential-shaped literals once, and both the single-statement and batch paths get it.
| .option("limit", { type: "number", default: 100, describe: "Max rows to return (0 for unlimited)" }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute multiple semicolon-separated statements sequentially" }) | ||
| .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) | ||
| .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" }) |
There was a problem hiding this comment.
LOW (confidence: high) — the rewrite drops the only hint in --help about how to pass more than one statement.
.option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" })Upstream of this change it read "execute multiple semicolon-separated statements sequentially". "all statements" no longer tells a reader where the statement boundary comes from, and nothing else in this command's help or epilogue says it. Adding the exit-code sentence does not require removing that: "Batch mode: execute multiple semicolon-separated statements sequentially; exit non-zero if any fails" keeps both.
One adjacent note while you are in this string, not a request to change code: "execute all statements sequentially" is accurate for the exit code but not for the output shape — --batch with a single statement never reaches the batch branch at line 706, it falls through to executeSingle at line 752 and prints the ordinary single-statement envelope rather than an indexed batch line. That asymmetry is pre-existing and out of scope here.
Review summaryA. Upstream invasiveness — no issues foundThe diff touches two files, both under B. Clean fix vs. hole drilled around the problem — no issues found on the substanceThis is the right fix, not a workaround. The root cause is structural: Two refactor-shaped observations, both inline rather than blocking:
No dead code, no leftover debug output, no copy-paste into a second place, and no unrelated drive-by edits. C. Regression riskEnumerated below. I did not run the test suite, so nothing here is a claim that anything passes. Intended behavior changes
Checked and found not to regress
Uncovered paths this change does not reach (pre-existing, listed for completeness, no action implied): |
When a statement fails in a multi-statement
sql --batchcommand, the CLI prints an error but previously exited with status 0. Automation could therefore treat a partially failed deployment as successful.Set the business-error exit code (1) for both failed SQL results and caught submission/execution exceptions. Continue processing subsequent statements and emitting per-statement results as before; successful statements do not clear the failure exit code. Document the exit behavior in
--batchhelp.Validation:
-eand-f, all-success batches, failed SQL results, and HTTP errors. They verify later statements still execute and their results are emitted.bun typecheckpassed inpackages/cz-cli.No production SQL was executed.