fix(bindx): classify readonly array columns as scalar fields - #103
Merged
Conversation
A `.list()` scalar column is generated as `readonly T[]`, and no `T[K] extends (infer U)[]` test matches a readonly array. Every array-shaped conditional in the accessor field-type mapping and in the selection builder therefore misread such a column: it fell through to the has-one branch, so the accessor exposed no `FieldAccessor` (no `.value` / `.setValue`) and `e.tags()` demanded a nested selection. A readonly has-many (`readonly Target[]`) was misread the same way. Align every site on the `NonNullable<T[K]> extends readonly (infer U)[]` plus `IsPlainObject<U>` idiom that `qb/inputTypes.ts` already used, and deduplicate `IsPlainObject` into `bindx-client/src/utils/fieldShape.ts`. `IsPlainObject` rather than `U extends object` is load-bearing: the latter distributes, so a JSON column whose type includes `readonly JSONValue[]` would be classified as a has-many. `FieldAccessor<T>` is invariant in `T`, so the reporter's assertion against `FieldAccessor<readonly string[]>` could never hold for a narrowed enum column; it now asserts against the column's own type plus explicit `.value` / `.setValue` shapes. Types only — no runtime behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A native list/array scalar column — a field typed
readonly T[]whereTis a primitive or a string enum (aenumColumn(...).list(), or any.list()scalar) — was misclassified by the accessor field-type mapping and by the selection builder. The accessor exposed noFieldAccessorfor it, so.value/.setValuedid not exist on the type and reading or writing the column fromcreateComponentexplicit selection did not compile.The runtime
FieldHandlehandles array columns fine. This is a types-only fix — no runtime behaviour changes.What was actually misclassified
The generator emits scalar list columns as
readonly T[](packages/bindx-generator/src/utils.ts) and has-many relations as mutableTarget[]. The key-set helpers inpackages/bindx/src/handles/types.tstestedT[K] extends (infer U)[], which no readonly array matches — so a list column skipped the array branch entirely and fell through to the has-one branch.The issue text says the column is "dropped from all three key sets". Measured, it is worse than that: it is classified as a has-one. Table below (verified by the assertions in this PR, run against the source before and after):
readonly ('a'|'b')[].list()enum columnreadonly string[] | nullnumber[]JSONValueboolean | nullid: stringTarget[]readonly Target[]Target | null/TargetThe
readonly Target[]row is a second, unreported half of the same bug: a hand-written readonly has-many was misread as a has-one, so its accessor had no.items/.map()/.add(). Nothing in the repo generates that shape today, which is why nobody hit it, but it is the same missingreadonly.The same mistake was in
SelectionBuilderMethods(packages/bindx-client/src/selection/types.ts), which testedTEntity[K] extends Array<infer U>. A readonly list column missed that test and landed onHasOneMethod, soe.tags()with zero arguments failed withTS2554: Expected 1-4 arguments, but got 0. (The issue comment attributes this toHasManyMethod; the actual routing isHasOneMethod. Same root cause, same symptom.)The fix
Every array-shaped conditional now uses the idiom that already existed correctly in
packages/bindx-client/src/qb/inputTypes.ts:Both halves are load-bearing:
readonly (infer U)[]matches mutable and readonly arrays, andNonNullable<...>makes a nullable column behave like its non-null form.IsPlainObject<U>, notU extends object.U extends objectdistributes over a union, so aJSONValue-typed json column whose type includesreadonly JSONValue[]would match the object members alone and be classified as a has-many.IsPlainObjectcollapses tobooleanfor such a union, and theextends truetest correctly fails.IsPlainObjectwas defined twice insidebindx-clientand exported from neither. It is now one shared internal type inpackages/bindx-client/src/utils/fieldShape.ts, exported from the package sobindxcan use it. Deduplicating also fixed a latent difference between the two copies: theselection/queryTypes.tsversion testedT extends Array<any>, so it answeredtruefor a readonly array (a readonly array is anobject) — which madeFieldsWhereandEntityOrderByproduce a nestedEntityWhere/EntityOrderByfor a readonly list column instead of a scalar condition.Files changed
packages/bindx-client/src/utils/fieldShape.ts— new, the sharedIsPlainObject.packages/bindx-client/src/index.ts— export it.packages/bindx-client/src/qb/inputTypes.ts— drop the local copy, import the shared one.packages/bindx-client/src/selection/queryTypes.ts— drop the local copy;FieldsWhere,EntityOrderBy,ArrayItemType,IsArraymade readonly-aware.packages/bindx-client/src/selection/types.ts—ArrayItemTypeandSelectionBuilderMethods(a scalar list column now routes toScalarMethod).packages/bindx/src/handles/types.ts—ScalarKeys/HasManyKeys/HasOneKeys,EntityFields,FieldRefType,FieldAccessorType, including the nestedExtractNestedSelection<TSelected, K> extends (infer S)[]tests.tests/typeSafety.test.ts— the reporters' repro plus the classification cases from the table.Sites deliberately left alone:
packages/bindx-ui/src/datagrid/columns/enum-column.tsxalready matchesreadonly (infer U)[] | null, and thereadonly unknown[]generic constraints inpackages/bindx-react/src/hooks/useFields.tsare tuple constraints, not field classification.Inference ripple
One, found and fixed while iterating. Rewriting the has-many branch of
FieldAccessorTypeas a directHasManyAccessor<HasManyItem<TEntity[K]>, …>(no wrapping conditional) broke the pre-existing assertion inpackages/bindx-react/src/jsx/proxyShared.ts:25withTS2352: neither type sufficiently overlaps. That cast only type-checks while the has-many branch stays a deferred conditional for an unresolvedTEntity; resolving it eagerly makes the comparison concrete and it fails. The branch is therefore kept in its original wrapped-conditional shape, just withreadonlyandIsPlainObject— which also keeps it consistent with the surrounding code. Nothing inproxyShared.tshad to change.A correction to the reporters' second assertion
assertTrue<AssertExtends<GroupSizeField, FieldAccessor<readonly string[]>>>()cannot hold, and not because of arrays:FieldAccessor<T>is invariant inT, becauseinputProps.setValueis a function-typed property (contravariant understrictFunctionTypes) whilevalueis covariant.FieldAccessor<'a' | 'b'>does not extendFieldAccessor<string>either — verified directly.The assertion now compares against the column's own type and checks the two members the issue is about:
Verification
Every assertion in the new block was confirmed load-bearing: with the test file at its final state and only the
packages/changes reverted,tscreports 12 errors across all of them; with the fix applied, none.bun run typecheck— clean.bun run test— 2018 pass, 0 fail, 7481expect()calls, 209 files.tests/browsernot run (needs a live playground).Fixes #58
🤖 Generated with Claude Code
https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R