Found while landing #17639 (the ADR-0112 envelope on the distinct() door). That card landed the TERMINAL envelope only — deliberately, following the precedent #11455 set on aggregate(), which left the identical gap FILED rather than guessed at and had it closed a card later by #11541. This is the distinct() half of that same gap.
Measured
driver-sql on branch claude/issue-17639-distinct-envelope (bbdc4ac74c, i.e. with #17639's envelope already in place), one object with a single title column, on two dialects. Every row below is the SAME condition — one column name the table does not have — asked at a different read door:
| door |
outcome |
message head |
count(t, { where: { nosuchcol: 1 } }) |
INVALID_FILTER / 400 |
Filter on 'nosuchcol' names a column that object 't' has no column for |
find(t, { where: { nosuchcol: 1 } }) |
INVALID_FILTER / 400 |
same |
aggregate(t, { groupBy: ['nosuchcol'], ... }) |
INVALID_FIELD / 400 |
The groupBy of this query names 'nosuchcol', a column that object 't' has no column for |
distinct(t, 'title', { nosuchcol: 1 }) |
DATABASE_ERROR / 500 |
The database refused to run this query for object 't'. The driver could not attribute the failure to any part of the request |
distinct(t, 'nosuchcol') |
DATABASE_ERROR / 500 |
same |
Identical on better-sqlite3 and on live PostgreSQL 16.13. The dialect raises the same column ... does not exist on every row; what differs is only which door was asked.
Why it matters
This is the split #8790 closed (maintainer ruling 2026-08-15: one unresolvable WHERE column, one answer, on both read halves), and that #11541 closed for the third door, still open on this call shape. A caller's own mistake — a filter key naming a field that does not exist — is served as a server fault that names nothing the caller can act on, where the same mistake one door over is a 400 naming the column. A picklist-populating distinct() call sits beside the find() and count() of the same list view.
Not a repeat of what #17639 landed
#17639 removed the raw dialect object from this door; the envelope itself is correct and catalogued. What is still missing is the ATTRIBUTION arm — and it was left out on purpose, with the reason written into sql-driver.ts at the door: a blanket isUnresolvableColumnError arm answers "Filter on 'x' names a column ...", and this door names columns in TWO clauses — the field being listed and the WHERE compiled from filters. Telling the author of distinct(o, 'nosuchcol'), who passed no filter at all, that their FILTER was wrong is the unsupportable attribution the #8931 ruling refuses to make.
The shape that already exists for this
SqlDriver.aggregateBackendFault (#11541) is the pattern: the dialect names the column, it does not name the clause, so the clause is read off the CALLER'S OWN request — there, the groupBy / aggregations AST the builder just compiled. For distinct() the two sources are the field argument and the filters AST, so the same three arms fall out: the name equals field (a refusal about the listed column), the name is in neither (the only remaining column sources are the WHERE and the tenant-scope predicate, so #8790's unresolvableFilterColumnRefusal applies verbatim), no parsed name at all (the #17639 terminal stands unchanged). No new dialect recognizer is needed: both predicates are #8790's, already shared by find(), count() and aggregate().
One thing the implementer should weigh up front
SqlDriver is an exported class, so a new protected member lands in driver-sql's built entry .d.ts — a published-surface move, which is a clause-2 input rather than a free choice. #11541 paid that cost for aggregate(); whether distinct()'s arms belong in a new member or inline at the door is a real decision, not a formatting one.
Repro
const d = new SqlDriver(cfg);
await d.initObjects([{ name: 't', fields: { title: { type: 'string' } } }]);
await d.count('t', { where: { nosuchcol: 1 } }); // INVALID_FILTER / 400
await d.distinct('t', 'title', { nosuchcol: 1 }); // DATABASE_ERROR / 500
Related
#8790 (the find()/count() ruling) · #11541 (the same arm delivered for aggregate()) · #11455 (the envelope card that left this gap filed one door over) · #8931 (the terminal envelope and the attribution it refuses) · #17639 (the distinct() envelope this is the attribution half of) · #8926 (why no wording-matched recognizer)
Filed unassigned by the #17639 dev. Recording only — no severity asserted, routing is triage's.
Generated by Claude Code
Found while landing #17639 (the ADR-0112 envelope on the
distinct()door). That card landed the TERMINAL envelope only — deliberately, following the precedent #11455 set onaggregate(), which left the identical gap FILED rather than guessed at and had it closed a card later by #11541. This is thedistinct()half of that same gap.Measured
driver-sqlon branchclaude/issue-17639-distinct-envelope(bbdc4ac74c, i.e. with #17639's envelope already in place), one object with a singletitlecolumn, on two dialects. Every row below is the SAME condition — one column name the table does not have — asked at a different read door:count(t, { where: { nosuchcol: 1 } })INVALID_FILTER/ 400Filter on 'nosuchcol' names a column that object 't' has no column forfind(t, { where: { nosuchcol: 1 } })INVALID_FILTER/ 400aggregate(t, { groupBy: ['nosuchcol'], ... })INVALID_FIELD/ 400The groupBy of this query names 'nosuchcol', a column that object 't' has no column fordistinct(t, 'title', { nosuchcol: 1 })DATABASE_ERROR/ 500The database refused to run this query for object 't'. The driver could not attribute the failure to any part of the requestdistinct(t, 'nosuchcol')DATABASE_ERROR/ 500Identical on better-sqlite3 and on live PostgreSQL 16.13. The dialect raises the same
column ... does not existon every row; what differs is only which door was asked.Why it matters
This is the split #8790 closed (maintainer ruling 2026-08-15: one unresolvable WHERE column, one answer, on both read halves), and that #11541 closed for the third door, still open on this call shape. A caller's own mistake — a filter key naming a field that does not exist — is served as a server fault that names nothing the caller can act on, where the same mistake one door over is a 400 naming the column. A picklist-populating
distinct()call sits beside thefind()andcount()of the same list view.Not a repeat of what #17639 landed
#17639 removed the raw dialect object from this door; the envelope itself is correct and catalogued. What is still missing is the ATTRIBUTION arm — and it was left out on purpose, with the reason written into
sql-driver.tsat the door: a blanketisUnresolvableColumnErrorarm answers "Filter on 'x' names a column ...", and this door names columns in TWO clauses — thefieldbeing listed and the WHERE compiled fromfilters. Telling the author ofdistinct(o, 'nosuchcol'), who passed no filter at all, that their FILTER was wrong is the unsupportable attribution the #8931 ruling refuses to make.The shape that already exists for this
SqlDriver.aggregateBackendFault(#11541) is the pattern: the dialect names the column, it does not name the clause, so the clause is read off the CALLER'S OWN request — there, thegroupBy/aggregationsAST the builder just compiled. Fordistinct()the two sources are thefieldargument and thefiltersAST, so the same three arms fall out: the name equalsfield(a refusal about the listed column), the name is in neither (the only remaining column sources are the WHERE and the tenant-scope predicate, so #8790'sunresolvableFilterColumnRefusalapplies verbatim), no parsed name at all (the #17639 terminal stands unchanged). No new dialect recognizer is needed: both predicates are #8790's, already shared byfind(),count()andaggregate().One thing the implementer should weigh up front
SqlDriveris an exported class, so a newprotectedmember lands indriver-sql's built entry.d.ts— a published-surface move, which is a clause-2 input rather than a free choice. #11541 paid that cost foraggregate(); whetherdistinct()'s arms belong in a new member or inline at the door is a real decision, not a formatting one.Repro
Related
#8790 (the
find()/count()ruling) · #11541 (the same arm delivered foraggregate()) · #11455 (the envelope card that left this gap filed one door over) · #8931 (the terminal envelope and the attribution it refuses) · #17639 (thedistinct()envelope this is the attribution half of) · #8926 (why no wording-matched recognizer)Filed unassigned by the #17639 dev. Recording only — no severity asserted, routing is triage's.
Generated by Claude Code