fix(driver-sql): attribute an unresolvable distinct() column to the clause the caller named - #17945
Conversation
…lause the caller named `distinct()` was the last read door answering DATABASE_ERROR / 500 for one unresolvable column, where find()/count() answer INVALID_FILTER / 400 and aggregate() answers INVALID_FIELD / 400. #17639 landed the terminal envelope here and deliberately left the attribution arm filed: a blanket isUnresolvableColumnError arm would tell the author of distinct(o, 'nosuchcol'), who passed no filter at all, that their filter was wrong. distinctBackendFault reads the dialect-named column back against the caller's own request, the shape #11541 established for aggregate(): the name equals the field argument => INVALID_FIELD / 400 naming the listed column; it does not => the only remaining column sources are the WHERE and the tenant-scope predicate, so #8790's filter refusal applies verbatim; no parsed name => the #17639 terminal stands. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Its readings are recorded in the PR body and in the pin suite's head note; the suite's own 'all four read doors' case asserts the same five rows. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
…he nested-filter pin table check:doc-authoring reds on a new (file,id) pair in sibling-package string prose: a runtime log line reaches operators and generated surfaces where '#NNNN' resolves against nothing. Ids moved to the adjacent comment. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 601198f3615219085396b8ddb93654c8d0fe17ba && git checkout 601198f3615219085396b8ddb93654c8d0fe17ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9 15f26e6210ee49f2eba2ea575ede958784e6a6f3 && git checkout -B drift-repro bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9 && git merge --no-ff 15f26e6210ee49f2eba2ea575ede958784e6a6f3
node scripts/docs-audit/affected-docs.mjs --json bd25e897dc3cf9cf50af9dd23a7d36948ade6bd9
|
Contract reviewHead reviewed: ① derived judgments
② semver 定级
③ 边界旗处置 —— ⭐ 裁定 open question:A(已落地的补集),⛔ 不是本席派发令里的 Bdev 提的问题:arm 2 落成了 arm 1 的补集(非 本席独立复核,裁 A,理由是测量不是偏好:
⇒ ⛔ 这不需要上呈维护者:它不是新契约决定,而是 #8790 既有裁决 + #11541 既有形状的应用。dev 把它标成「maintainer may want to confirm」是稳妥的,本席据以上三条把它在席内裁掉。 其余两条
独立性对Implemented-by: claude/issue-17857-distinct-column-attribution 判词PASS。 ①②③ 无欠改,open question 已在席内裁定为 A(即已落地形态,⛔ 无需改动)。落地前检:① 本记录;② Generated by Claude Code |
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 foraggregate().The five rows, run rather than reasoned about
Embedded SQLite, one object with a single
titlecolumn, one column name the table does not have. Before =origin/mainatdbea1756d9; after = this branch at15f26e6210, where the full package suite, the repo lint and the derived gate set were all re-run.count(t, { where: { nosuchcol: 1 } })INVALID_FILTER/ 400find(t, { where: { nosuchcol: 1 } })INVALID_FILTER/ 400aggregate(t, { groupBy: ['nosuchcol'] })INVALID_FIELD/ 400distinct(t, 'title', { nosuchcol: 1 })DATABASE_ERROR/ 500INVALID_FILTER/ 400distinct(t, 'nosuchcol')DATABASE_ERROR/ 500INVALID_FIELD/ 400After-row 4 is byte-identical to what
find()andcount()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.distinctBackendFaultreads the dialect-named column back against the caller's own request. The dialect names the column; it does not name the clause.fieldargument ⇒INVALID_FIELD/ 400 naming the listed column, carrying thefieldandobjectriders the ingress door's refusals carry;filtersand the tenant-scope predicate, both filters, so driver-sql: one unresolvable WHERE column, two answers —find()silently returns [] whilecount()throws a raw dialect error with no ADR-0112 envelope #8790's existingunresolvableFilterColumnRefusalapplies verbatim;DATABASE_ERROR/ 500 stands unchanged.No blanket
isUnresolvableColumnErrorarm: 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 onesfind(),count()andaggregate()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
filtersAST" 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-objectdiffering from the HEAD blob, ran the suite, restored withgit checkout HEAD -- PATH, and proved the restore by the on-disk blob hash equalling the HEAD blob again withgit diff HEADempty.filterskey searchLeg 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:FilterConditionnests, 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 isselect distinct FIELD from TABLE where FILTERS-PLUS-TENANT-SCOPE, so a column reference that is not thefieldcame from a predicate.Marker hashes, for the record: HEAD blob
6b7457d056; leg A18c0dcaf80, leg Bee90517fce, leg Cabcb2146c3; all three restored to6b7457d056with a residual marker count of 0.Changeset — measured, not assumed:
patchThe expectation was
patch; the measurement confirms it rather than being taken from the sibling's precedent.@objectstack/driver-sqldeclaresfiles: ["dist","README.md","CHANGELOG.md"], sodist/was built and grepped with both controls:aggregateBackendFault(published since #11541)dist/index.js,dist/index.mjs,dist/index.d.ts,dist/index.d.mtsdistinctBackendFaultlists the distinct values ofdist/index.js,dist/index.mjsdistinctBackendFaultNeverShippedClassifierProbeA
protectedmember does reach the published declaration:Published bytes move ⇒
patch, neverskip-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 theTemporal 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-wideeslint . --no-inline-config) — exit 0, clean, no narrowing declared.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 allPREREQUISITE 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-authoringcaught a real defect in this diff's first draft — the newlogger.warnstring 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
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 inSqlDriver.distinct's own docblock (the [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #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 thedriver-memoryfilter-narrowing parity work; the note sits in the docblock a reader of this door already passes through.jsoncolumn 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 driver-sql: distinct() leaks the raw backend error with status undefined — the ADR-0112 envelope #11455 gave aggregate() never reached the third read door #17639's terminal.sql-driver-17639-distinct-fault-envelope.test.tsis unchanged except for its head note, which said thedistinct()attribution half "is filed as its own card" — made false by this PR and corrected in the same diff.Generated by Claude Code