Skip to content

fix(driver-sql): aggregate() joins the enveloped read exits — a dialect fault it cannot attribute leaves as DATABASE_ERROR/500 instead of raw - #11545

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11455-bool-aggregate-envelope
Aug 24, 2026
Merged

fix(driver-sql): aggregate() joins the enveloped read exits — a dialect fault it cannot attribute leaves as DATABASE_ERROR/500 instead of raw#11545
os-zhuang merged 1 commit into
mainfrom
claude/issue-11455-bool-aggregate-envelope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#11455

SqlDriver.aggregate() executed its statement bare — no try, no envelope. Every dialect error the backend raised left the driver as the backend's own error object: a code from the backend's vocabulary, no status at all, and a message opening with the compiled statement. find() and count() have carried the terminal ADR-0112 envelope since #8931; this third read door was simply never given it.

The measurement

Live PostgreSQL 16.13 (Asia/Shanghai server zone, TZ=America/New_York process, CI parity), driver-sql maps a boolean field to a real PG boolean column and SQL_AGGREGATE_FUNCTIONS lowers the arithmetic aggregates to a bare function name with no cast:

sum(flag) => THREW code=42883 status=undefined
msg=select sum("flag") as "n" from "…" - function sum(boolean) does not exist

A raw 42883 is on no list @objectstack/rest reads, so with status undefined an ordinary analytics shape — a rate measure over a flag column — was logged as an unhandled server fault, and the statement's shape travelled to the caller with it.

The change

One guarded execution in aggregate() (packages/drivers/driver-sql/src/sql-driver.ts, a single hunk at @@ -7634,7 +7634,57 @@):

letrows: unknown[];try{rows=awaitbuilder;}catch(error){throwthis.backendStatementFault(object,error);}returnthis.presentReadColumns(rows,presentedOutput);

Verified at the call sites, not at the symbol name (a good fix names the old symbol in its docblock, so a symbol-name grep proves nothing):

5064: throw this.backendStatementFault(object, error); ← findRows terminal (#8931)
7321: throw this.backendStatementFault(object, error); ← count() (#8931)
7686: throw this.backendStatementFault(object, error); ← aggregate() (this PR)

whole-file literal count of that call expression: 3; definition sites of protected backendStatementFault(: 1.

Why DATABASE_ERROR / 500 — argued from the code, and no code was minted

ADR-0112's vocabulary is closed. D3/D4 closed StandardErrorCode, and D2's 2026-08-18 amendment retired three members on the reasoning that an unreachable-but-declared code teaches a branch that can never fire. So the choice is among catalogued members, and the one chosen is the one the sibling read exits already answer with.

backendStatementFaultError (#8931, maintainer ruling 2026-08-17 「同意 C」) asserts exactly one thing — the backend would not run this statement — and its docblock states why the alternatives are unavailable here, word for word applicable to this door: 400 "would assert the caller's request is at fault, and this envelope exists precisely because the driver cannot attribute the failure"; 500 "keeps the answer inside declaresServerFault, so both HTTP doors withhold the prose and log it". The registered near-misses were already measured and rejected on that card (UNSUPPORTED and UNSUPPORTED_QUERY_PARAM are 400s on the client-mistake list, UNSUPPORTED_TRANSFORM belongs to @objectstack/rest's import mapper).

The three refusals this door can attribute keep their own precise codes and are unaffected — all are raised while the statement is built, upstream of the guarded execution:

conditioncode / statussince
a function the spec does not declareINVALID_QUERY / 400#5907
count_distinct with no fieldINVALID_QUERY / 400#6409
a per-aggregation filterNOT_IMPLEMENTED / 501#10576

The fence: this is the envelope half, and it decides no contract

Whether the platform should answer a number for an arithmetic aggregate over a boolean (by casting boolean to int in the lowering) or refuse is the open contract question on #11152, and on #11249 for min/max. Nothing here pre-empts either card, and both remain open:

Cross-dialect reading (the card asked for it; MySQL was unmeasured)

Measured 2026-08-24 on the live servers in this container, raw SQL, boolean column as driver-sql creates it:

dialectstoragesumavgminmaxcount
PostgreSQL 16.13boolean428834288342883428836
MySQL 8.0.46tinyint(1)30.5000016
SQLite(as reported on the card)30.5falsetrue6

So MySQL answers arithmetically where Postgres refuses — three dialects, three behaviours, for one QueryAST. That divergence is reported to the PM rather than filed or folded in here, since it is evidence for #11152's contract question, not a defect of this envelope.

Reverse verification — the pins were seen red

The new suite was run against the pre-fix source. 23 of its cases failed, reproducing the card verbatim on all three dialects:

FAIL … aggregate() takes the backend-fault envelope (sqlite) > an unclassified dialect fault …
AssertionError: code: expected 'SQLITE_ERROR' to be 'DATABASE_ERROR'
FAIL … (live mysql) > an unclassified dialect fault on the aggregate door carries a code AND a status
AssertionError: code: expected 'ER_NO_SUCH_TABLE' to be 'DATABASE_ERROR'
FAIL … postgres — an arithmetic aggregate over a boolean column > the dialect text reaches the SERVER LOG …
AssertionError: expected '42883' to be 'DATABASE_ERROR'
FAIL … > find(), count() and aggregate() answer one unclassified fault the SAME way
AssertionError: aggregate vs find: expected { code: 'SQLITE_ERROR', …(2) } to deeply equal { … }
- "code": "DATABASE_ERROR",
- "message": "The database refused to run this query for object 'agg_fault_never_created'. …",
- "status": 500,
+ "code": "SQLITE_ERROR",
+ "message": "select count(*) as `n` from `agg_fault_never_created` - no such table: agg_fault_never_created",
+ "status": undefined,
FAIL … > claims NOTHING about whether a boolean aggregate should answer (#11152 / #11249)
AssertionError: expected 'select avg("flag") as "n" from "agg_f…' not to match /boolean|avg|flag/i
+ Received: "select avg(\"flag\") as \"n\" from \"agg_fault_bool\" - function avg(boolean) does not exist"

Test Files 1 failed | 125 passed (126) · Tests 23 failed | 2520 passed (2543) — the rest of the package was green pre-fix, so the 23 are this suite alone.

Tests — run at 31edafe0, a clean tree, live PG + MySQL provisioned

git rev-parse --short HEAD at the union run: 31edafe0; git status --porcelain empty.

runresult
pnpm --filter @objectstack/driver-sql test -- --maxWorkers=2Test Files 126 passed (126) · Tests 2543 passed (2543), 0 skipped
pnpm --filter @objectstack/driver-sql typecheck (tsc --noEmit)exit 0
pnpm lint (eslint . --no-inline-config, whole repo)exit 0, no findings — the farm scan was run, not narrowed
pnpm --filter @objectstack/driver-turso testTest Files 39 passed (39) · Tests 1006 passed (1006)
pnpm --filter @objectstack/driver-sqlite-wasm testTest Files 25 passed (25) · Tests 395 passed (395)

Both subclass drivers extend SqlDriver and inherit this method, which is why they are the consumer set that was run.

Gate family derived from the real change set with node scripts/pm/dispatch-gates.mjs (no hand-written path list), every one green, each quoted from the gate's own verdict line rather than from a shell exit code:

check:nul-bytes (OK (scanned 6461 text file(s) … no raw ASCII control bytes)) · check:driver-conformance (OK — 45 covered cell(s), 0 in the DEBT ledger) · check:published-files · check:slot-lookup (107 unswept site(s) …, none new; baseline key set verified against daacc10: no files added) · check:test-source-alias · check:type-source-resolution · check:query-options-erasure · check:type-check-coverage · check:engine-double-contract (OK — 394 pinned, 133 in the DEBT ledger) · check:cross-package-test-inputs (14 package(s) read outside themselves, all declared) · check:where-matcher (290 matcher(s) …, 0 silently-wrong … none new) · check:error-code-casing · check:changeset-gate-self-tests · check:objectui-changeset · check-adr-0087-registration (adds no declared-breaking changeset (1 non-breaking changeset(s) seen)) · check-changeset-no-major · check-empty-changeset (1 declaring changeset(s) added) · check-ci-filter-parity · check-plugin-teardown-shape · docs-audit/check-affected-docs.

One declared narrowing:check:type-check-debt's --re-measure half was not run locally — it re-runs tscper ledger entry, and @objectstack/driver-sql carries no entry in DEBT, EXEMPT or TEST_DEBT (all three checked in scripts/check-type-check-coverage.mjs). Its tsconfig.jsoninclude is src/**/* with no test exclusion, so the new test file is inside the program that pnpm --filter @objectstack/driver-sql typecheck just compiled clean, and the structural half (check:type-check-coverage, plus its self-test) is green. CI runs the ratchet regardless.

The new suite

packages/drivers/driver-sql/src/sql-driver-11455-aggregate-fault-envelope.test.ts, built on live-dialect-matrix.testkit.ts so the dialect axis is a declared cell list rather than a hard-coded client — an unprovisioned cell is a named skip, never a silent pass, and the axis itself is asserted (['sqlite','pg','mysql']) before it is iterated.

All three dialects: the envelope on an unclassified fault · one answer across find/count/aggregate · no filter or query verdict claimed · the original kept as a non-enumerablecause (so isMissingTableError stays truthful and JSON.stringify carries no dialect text) · the dialect diagnostic present in the server log. Postgres only: each of sum/avg/min/max over a boolean column carrying DATABASE_ERROR / 500 with SQLSTATE 42883 on the cause, plus a positive control that the log really did contain function avg(boolean) does not exist — so the caller-visible absence is a withholding, not an assertion about a string that never held it.

Controls that would catch the two ways this could go wrong: a working aggregate untouched, and all three precise refusals still winning over the catch-all.

Region fence

sql-driver.ts carries exactly one hunk, @@ -7634,7 +7634,57 @@. The three sibling-claimed regions are untouched — toDateOnly, createColumn and introspectForeignKeys each appear on 0 changed lines of the diff.

Out of scope, filed rather than fixed

#11541 — an unresolvable WHERE column on aggregate() answers DATABASE_ERROR / 500 where find() and count() answer INVALID_FILTER / 400. Measured with this branch in place. It is not fixed here because a blanket isUnresolvableColumnError arm on this door would be wrong: the refusal's words are "Filter on '…' names a column that object '…' has no column for", and this door names columns in three clauses — the WHERE, the groupBy fields and the aggregation field — so an author who wrote avg('nosuchcol') would be told their filter was at fault. Answering it truthfully needs a clause classifier this card did not measure. #11541 remains open and carries the measurement.

Not addressed here, out of scope: #11152 and #11249, the contract question above.


Generated by Claude Code

`SqlDriver.aggregate()` executed its statement bare, so every dialect error
left the driver as the backend's own object — a raw SQLSTATE in `code`,
`status` undefined, and the compiled statement as the message. Measured on
live PostgreSQL 16.13, `avg`/`sum`/`min`/`max` over a `boolean` column raise
`42883 function avg(boolean) does not exist`, which is on no list
`@objectstack/rest` reads: an ordinary analytics shape was logged as an
unhandled server fault.
The door now composes the same terminal `backendStatementFaultError` that
`find()` and `count()` have carried since #8931 — `DATABASE_ERROR` / 500, the
dialect diagnostic to the server log, the original kept as a non-enumerable
`cause`. No new error code (ADR-0112 D3/D4 closed the vocabulary), no
boolean-specific recognizer, and no verdict about whether a boolean aggregate
should answer a number or refuse — that contract question is #11152's, and
#11249's for `min`/`max`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4c9780c7ace66a9ded339c5b261465ad89765b1fpackageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

driver-sql (PG): sum/avg/min/max over a boolean column throw the raw PostgreSQL 42883 with no ADR-0112 envelope (status undefined)

2 participants

@os-zhuang@claude