Repository navigation
Commit 9ccc417
fix(driver-sql): attribute an unresolvable distinct() column to the clause the caller named (#17945)
Fixes #17857
Clause-②: no — the call is refused either way; what changes is the
refusal's envelope and attribution. No input that was previously refused
becomes accepted, and no exported symbol moves.
`distinct()` was the last read door answering a **server fault** for one
unresolvable column. This lands the attribution arm #17639 deliberately
left filed, in the shape #11541 established for `aggregate()`.
## The five rows, run rather than reasoned about
Embedded SQLite, one object with a single `title` column, one column
name the table does not have. Before = `origin/main` at `dbea1756d9`;
after = this branch at `15f26e6210`, where the full package suite, the
repo lint and the derived gate set were all re-run.
| door | before | after | arm |
|:--|:--|:--|:--|
| `count(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 |
unchanged | (not this door) |
| `find(t, { where: { nosuchcol: 1 } })` | `INVALID_FILTER` / 400 |
unchanged | (not this door) |
| `aggregate(t, { groupBy: ['nosuchcol'] })` | `INVALID_FIELD` / 400 |
unchanged | (not this door) |
| `distinct(t, 'title', { nosuchcol: 1 })` | **`DATABASE_ERROR` / 500**
| **`INVALID_FILTER` / 400** | **arm 2** (a predicate named it) |
| `distinct(t, 'nosuchcol')` | **`DATABASE_ERROR` / 500** |
**`INVALID_FIELD` / 400** | **arm 1** (the listed field) |
After-row 4 is **byte-identical** to what `find()` and `count()` answer
for the same filter — asserted as message equality, not merely as the
same code, because a drifted wording is how one condition acquires two
explanations. After-row 5 names the listed column and the word *filter*
appears nowhere in it.
## The three arms
`SqlDriver.distinctBackendFault` reads the dialect-named column back
against the caller's own request. The dialect names the column; it does
not name the clause.
1. the name **equals the `field` argument** ⇒ `INVALID_FIELD` / 400
naming the listed column, carrying the `field` and `object` riders the
ingress door's refusals carry;
2. it does not ⇒ the statement's only remaining column sources are the
WHERE compiled from `filters` and the tenant-scope predicate, both
filters, so #8790's existing `unresolvableFilterColumnRefusal` applies
verbatim;
3. the wording yields **no name** ⇒ no attribution is supportable and
#17639's terminal `DATABASE_ERROR` / 500 stands unchanged.
No blanket `isUnresolvableColumnError` arm: the classifier is what makes
that claim unnecessary, not what makes it safe. No code is minted and no
new dialect recognizer is added — both predicates are the ones `find()`,
`count()` and `aggregate()` already share.
### One deliberate departure from the dispatch brief, with the
measurement behind it
The brief paraphrased arm 2 as *"the name appears in the `filters` AST"*
with *"neither"* falling to the terminal. The card's own text is the
#11541 mirror — arm 2 is the **complement** of arm 1 — and that is what
landed, because the two designs are distinguishable and one of them
re-opens the defect. See ablation leg C below. Both designs produce the
identical five rows above, so the divergence is invisible to the card's
own probe.
## Ablation — three legs, each with on-disk proof and a verified restore
The fix was committed first; every leg mutated the committed file,
proved the mutation reached disk by marker count and by `git
hash-object` differing from the HEAD blob, ran the suite, restored with
`git checkout HEAD -- PATH`, and proved the restore by the on-disk blob
hash equalling the HEAD blob again with `git diff HEAD` empty.
| leg | mutation | result |
|:--|:--|:--|
| A | delete arm 1 (i.e. the forbidden blanket arm) | **6 failed** / 12
passed — every arm-1 case, the five-row case and the tie-break |
| B | delete arm 2 (every non-field column falls to the terminal) | **6
failed** / 12 passed — every arm-2 case, both dialect-wording units and
the five-row case |
| C | replace the complement with a top-level `filters` key search | **3
failed** / 15 passed |
Leg C is the discriminating one. The **flat**-filter case (`arm 2: a
filter column gets byte-identical treatment at all three doors`) stayed
**green**, while the **nested** case failed:
```
AssertionError: $or: code: expected 'DATABASE_ERROR' to be 'INVALID_FILTER'
```
`FilterCondition` nests, so an AST search silently downgrades `{ $or: [{
nosuchcol: 1 }] }` back to the 500 this card removes — the defect
itself, one nesting level down, invisible to any pin written on a flat
filter. The complement needs no walker to be complete: the compiled
statement is `select distinct FIELD from TABLE where
FILTERS-PLUS-TENANT-SCOPE`, so a column reference that is not the
`field` came from a predicate.
Marker hashes, for the record: HEAD blob `6b7457d056`; leg A
`18c0dcaf80`, leg B `ee90517fce`, leg C `abcb2146c3`; all three restored
to `6b7457d056` with a residual marker count of 0.
## Changeset — measured, not assumed: `patch`
The expectation was `patch`; the measurement confirms it rather than
being taken from the sibling's precedent. `@objectstack/driver-sql`
declares `files: ["dist","README.md","CHANGELOG.md"]`, so `dist/` was
built and grepped with both controls:
| reading | result |
|:--|:--|
| positive control `aggregateBackendFault` (published since #11541) |
hits `dist/index.js`, `dist/index.mjs`, `dist/index.d.ts`,
`dist/index.d.mts` |
| this change's marker `distinctBackendFault` | hits the **same four**
published files |
| this change's refusal sentence `lists the distinct values of` | hits
`dist/index.js`, `dist/index.mjs` |
| fabricated negative control `distinctBackendFaultNeverShipped` | **0**
|
| test-only symbol `ClassifierProbe` | **0** |
A `protected` member does reach the published declaration:
```
3819: protected distinctBackendFault(object: string, field: string, error: unknown): Error;
3860: protected unresolvableDistinctColumnRefusal(object: string, column: string, error: unknown): Error;
```
Published bytes move ⇒ `patch`, never `skip-changeset`.
## Verification
- `pnpm --filter @objectstack/driver-sql test` — **exit 0**, 177 files
passed / 11 skipped, 2620 tests passed / 166 skipped. The skips are the
live PG and MySQL cells, unprovisioned here and covered by the `Temporal
Conformance (live PG + MySQL)` job.
- `pnpm --filter @objectstack/driver-sql typecheck` — **exit 0**.
- `pnpm --filter '@objectstack/driver-sql^...' build` — **exit 0**
(dependency closure).
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — **exit 0**,
clean, no narrowing declared.
- Gate families derived from the diff with
`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: **62 derived, 62 run, 59 exit 0**, re-run
in full at the final HEAD. The three non-zero are all `PREREQUISITE NOT
MET` / **exit 3 — NOT MEASURED, neither pass nor failure** — because
they read built output for the whole workspace:
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`. CI builds the workspace before those steps.
- `check:doc-authoring` caught a real defect in this diff's first draft
— the new `logger.warn` string carried `(#7929, #17857)`, and a log line
reaches operators and generated surfaces where an issue id resolves
against nothing. The ids moved to the adjacent comment; the gate is
green.
## Acceptance notes
- **Not filed, noted here.** `driver-memory`'s half of the bare-filter
asymmetry — a bare filter there returns the *unfiltered* set in silence
— is still open and is already recorded in `SqlDriver.distinct`'s own
docblock (the #5499 freeze dissolved 2026-08-11 and nothing has claimed
it since). Untouched by this card, which is about a column that does not
exist rather than a filter that is silently dropped. Carrier: whoever
takes the `driver-memory` filter-narrowing parity work; the note sits in
the docblock a reader of this door already passes through.
- **Fence held.** #17590 (whether a `json` column should *answer* a
distinct read) is not addressed here and remains open: that is a column
that **exists** whose type has no equality operator, so it is not an
unresolvable-column error and still leaves as #17639's terminal.
`sql-driver-17639-distinct-fault-envelope.test.ts` is unchanged except
for its head note, which said the `distinct()` attribution half "is
filed as its own card" — made false by this PR and corrected in the same
diff.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2c87a48 commit 9ccc417
4 files changed
Lines changed: 650 additions & 16 deletions
File tree
- .changeset
- packages/drivers/driver-sql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
78 | | - | |
79 | | - | |
80 | | - | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
81 | 84 | | |
82 | 85 | | |
83 | 86 | | |
| |||
0 commit comments