Skip to content

fix(components)!: the filter builder's row takes the spec rule's value, so an authored group reaches it without a cast - #26338

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-aqazie
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-aqazie

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #26306

Clause-②: yes (narrowing)
Metadata-protocol: none

BREAKING type-surface change, compile-time only. FilterBuilderRow is exported from @object-ui/components (export * from './custom'). What the builder accepts widens; what a reader of row.value (a host's onChange handler) may assume narrows, because the member is now optional and admits null. The changeset carries the reader's migration and the ADR-0087 disposition not-required (runtime-interface-only ui/packages/components/src/custom/filter-builder.tsx#FilterBuilderRow). It ships patch (Metadata-protocol: none).

What changed

The filter-builder renderer hands schema.value (the authored FilterGroup from @object-ui/types) to FilterBuilder, which is typed on its own FilterBuilderRowGroup. The builder's row now takes the spec rule's value by reference, so the authored group is assignable to the builder's group and the renderer needs no cast.

  • FilterBuilderRow.value (ui/packages/components/src/custom/filter-builder.tsx): ViewFilterRule['value'] from @objectstack/spec/ui, plus the builder's own list member (string | number | boolean)[], and optional. It was string | number | boolean | (string | number | boolean)[], required.
  • retypeFilterValue: a scalar with no value (undefined, null, "") is returned as it is instead of being converted. isValueUnset became a type predicate, which also removes the cast inside normalizeToArray.
  • The renderer (src/renderers/complex/filter-builder.tsx): declares value?: FilterGroup on its props, because a host that binds the node forwards the group it holds. Both casts go: value was never cast on main (the any index signature carried it), and fields={(schema.fields || []) as any} becomes fields={schema.fields || []} (FilterField[] was already assignable).
  • docs/FilterBuilder.md shows the new member. Changeset: .changeset/26306-filter-builder-spec-row-value.md (patch, BREAKING banner, migration for readers of row.value).

Measurement: the spec's rule against both shapes

Spec: ViewFilterRuleSchema in packages/spec/src/ui/view.zod.ts, read at 61ba04f68. Types: FilterBuilderCondition / FilterGroup in ui/packages/types/src/complex.ts. Builder: FilterBuilderRow / FilterBuilderRowGroup.

case spec ViewFilterRule types FilterBuilderCondition builder FilterBuilderRow on main here
value omitted (value-less operator) value? value?: any required optional, pinned
value null admitted admitted (any) refused admitted, pinned
boolean entry in a list or pair refused admitted (any) admitted unchanged (see Acceptance notes)
operator VIEW_FILTER_OPERATORS ViewFilterOperator by reference those + opt-in exists / notExists unchanged: every spec id is a builder id; the opt-in pair is the emit-side extension objectui#9559 ruling B keeps
row id none (a console decoration the write path strips) required (objectui#8415) taken from types unchanged: types and builder agree
group none in the spec (a rule array) { id?, logic, conditions } conditions restated only unchanged

The spec row itself needs no change, so nothing here goes to domain:spec.

The two value cases were not only type gaps. A field switch ran the absent value through convertScalarToFamily: a value-less row switched to a number column threw TypeError: Cannot read properties of undefined (reading 'trim') (null likewise), and on a text column it came back as the string "undefined".

Tests

New file src/__tests__/filter-builder-spec-row-26306.test.tsx, one pin per case this PR changes, driven through the real field dropdown with an authored group typed FilterGroup (no cast):

  • value omitted: { field: 'title', operator: 'is_null' } switched to a number column keeps no value.
  • value null: { field: 'amount', operator: 'equals', value: null } draws a blank input and keeps null through a switch to a currency column.
  • Type pins (tsc -p tsconfig.test.json): FilterBuilderRow['value'] equals the spec's value plus the list member; FilterGroup and FilterField[] are assignable to the builder's props.

filter-builder-name-authority-9306.test.tsx: the value-extension pin now states the new member; the @ts-expect-error on a value-less row became a live fixture, and a new @ts-expect-error holds that an object value is still refused (the member is not any).

Measured (head 6a63ec93, origin/main da99a4bd merged in; the source under test is unchanged since becb866e)

  • Renderer with fix(types,components)!: renderer props index signatures map to unknown, not any #26293's signature, no cast. fix(types,components)!: renderer props index signatures map to unknown, not any #26293's renderer-host-props.ts, its base.ts line ([key: string]: unknown) and its renderer annotation ([key: string]: unknown } & RendererHostProps) applied locally on top of this branch, with no as in the renderer: tsc --noEmit in @object-ui/components exits 0. Control leg, same mutation over main's builder: exit 2, filter-builder.tsx(49,11): error TS2322: Type 'FilterGroup | undefined' is not assignable to type 'FilterBuilderRowGroup | undefined'. Neither change is pushed.
  • Reverse verification. The new suite against main's custom/filter-builder.tsx (byte-identical, hash checked; restored and checked against the HEAD blob): 2 of 2 red, TypeError: Cannot read properties of undefined (reading 'trim') and … of null (reading 'trim').
  • Tests. pnpm --dir ui exec vitest run --maxWorkers=2 over every packages/components/src/__tests__/filter-builder-* file, inline-locale-label-read-sites, share-filter-sort-spec-parity, record-picker-malformed-filter-10789, plugin-list's two convertFilterGroupToAST suites and app-shell's userFilterUrlState.listState-11860: Test Files 26 passed (26), Tests 505 passed (505). Narrowed to the row's readers; the full package suites are CI's Test UI legs.
  • Type-check. pnpm --filter @object-ui/components --filter @object-ui/plugin-list --filter @object-ui/plugin-view --filter @object-ui/app-shell run type-check (both programs each, after building their dependency closure): exit 0. These are the packages that import FilterBuilderRow / FilterBuilderRowGroup.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 45 commands at 6a63ec93; all 45 exit 0, and --ran reconciles 45 run, 0 NOT-MEASURED, 0 UNRUN. Among them check-sdui-manifest.mjs, check-changeset-no-major.mjs --base origin/main and check-adr-0087-registration.mjs --base origin/main, whose verdict line reads: ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. ([BREAKING+bang+clause-②-narrowing] not-required (runtime-interface-only) -- verified: ui/packages/components/src/custom/filter-builder.tsx#FilterBuilderRow (interface)).
  • ADR-0087 dispositions measured (each committed on a throwaway commit at 3abe2733, gate run, commit dropped): runtime-interface-only with the prose migration: exit 0. runtime-interface-only with a FROM / TO code block: exit 1, refused as a migration prescription (D7 inherits that refusal). type-surface-only, with either migration: exit 1, predicate 4 (FilterBuilderRow was already concrete at the merge base). So the migration for readers is prose, not a FROM / TO block.
  • Dogfood (owed for the (narrowing) arm), 6a63ec93, pnpm --filter @objectstack/dogfood test under the lock after building its closure: Test Files 297 passed, 3 skipped (300); Tests 2528 passed, 18 skipped (2546); VERDICT command-exit 0.
  • Console. pnpm exec turbo run build --filter=@object-ui/console --concurrency=2 at becb866e: 71 tasks successful, VERDICT command-exit 0.
  • sdui. node scripts/gen-sdui-manifest-node.mjs re-recorded scripts/sdui-manifest.sources/components.json only; sdui.manifest.json unchanged. fix(types,components)!: renderer props index signatures map to unknown, not any #26293 (held) and feat(types,react,sdui-parser)!: retire the node-level visibleOn / hiddenOn / disabledOn spellings #26274 also move that record, so whichever lands later merges main and re-records.

Acceptance notes

  • The boolean list member is kept. The spec's list holds strings and numbers; the builder's admits booleans and can emit one: an authored equals true row on a select column, switched to in, comes back as [true], which ViewFilterRuleSchema refuses. Narrowing the member to the spec's was built and measured on this branch first: it is a breaking change to a published type, and @object-ui/plugin-list's UserFilters fails to compile against it (it hands LookupValuePicker a (string | number | boolean)[]). It needs a file outside this claim, and its writers (UserFilters) would need a FROM → TO rewrite, which no ADR-0087 category admits for a type that was already concrete at the base (type-surface-only fails predicate 4, runtime-interface-only refuses a prescription; both measured, see below). So it is reported for its own card rather than made here. The builder's doc comment and the new test name the width as it is.
  • FilterBuilderSchema.onChange is declared (filter: FilterGroup) => void, but the renderer calls the host's onChange with { target: { name, value } }. Unchanged here; noted.
  • fix(types,components)!: renderer props index signatures map to unknown, not any #26293 drops its as any on value once this lands; its renderer annotation then type-checks as measured above.

Generated by Claude Code

…lue widens to the spec's

Narrowing the list member to the spec's is a breaking change to a published
type with a consumer in @object-ui/plugin-list (UserFilters hands
LookupValuePicker a boolean-bearing list); it is reported, not made here.

Claude-Session: https://claude.ai/code/session_01QGA6bzuch27y29tUbeb1y9
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tooling labels Oct 26, 2026
@objectstack-fleet objectstack-fleet Bot changed the title fix(components): the filter builder's row takes the spec rule's value, so an authored group reaches it without a cast fix(components)!: the filter builder's row takes the spec rule's value, so an authored group reaches it without a cast Oct 26, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT + contract review · seat domain:ui#1 · 2026-10-26T22:24Z

Head 6a63ec93 (read from the PR object): the seat's review round on becb866e plus a main merge (da99a4bd). The source under test is unchanged since becb866e.

  • Shape. Draft on main, Fixes #26306. fix(components)!:. 7 files: custom/filter-builder.tsx, renderers/complex/filter-builder.tsx, their tests (a new filter-builder-spec-row-26306 suite, one test per divergent value case), docs/FilterBuilder.md, the re-recorded components.json, and a root changeset (@object-ui/components patch).
  • Checks pinned to the head. All check runs completed: 0 failing, 0 pending. The seat's landing script checked this before posting.
  • Governed surface. check-governed-merges.mjs --pr 26338: not governed.
  • Contract review: PASS.
    • The measurement found three divergent value cases between the spec's ViewFilterRule and the builder row: value omitted, value null, and a boolean list entry. No operator diverged, and the spec needs no change.
    • FilterBuilderRow.value now takes ViewFilterRule['value'] by reference, plus the builder's own list member, and is optional. The renderer declares value?: FilterGroup and drops its as any on fields.
    • With fix(types,components)!: renderer props index signatures map to unknown, not any #26293's unknown index signature applied locally, the renderer type-checks with no cast. The control leg over main gives TS2322.
    • It also fixes a real crash: switching the field of a value-less or null row threw on .trim().
  • Clause-② re-declared at the seat's review. FilterBuilderRow is public through export * from './custom', and hosts read row.value. So widening what the builder accepts narrows what a reader may assume. The declaration is yes (narrowing) with a BREAKING banner. It ships patch as a non-protocol breaking change. Metadata-protocol: none.
  • ADR-0087. Gate verdict on 6a63ec93: "✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition": not-required (runtime-interface-only), verified on FilterBuilderRow (interface).
    • The reader migration is prose (handle undefined/null, or narrow with the existing unset check) rather than a FROM → TO block. The dev measured both forms: D7 refuses a prescription under runtime-interface-only, and type-surface-only fails predicate 4 because the row was concrete at base. The seat accepts the prose.
  • Evidence (dev, on 6a63ec93). Dogfood: 297 files passed and 3 skipped; 2528 tests passed and 18 skipped; VERDICT exit 0. 45/45 gates, 0 NOT-MEASURED. Filter-builder family: 26 files, 505 tests. Type-check of components, plugin-list, plugin-view and app-shell exits 0. Console build passes.
  • Out of scope, filed. Narrowing the boolean list member goes to its own card, components / plugin-list: the filter builder emits [true] for a boolean row switched to In, which the spec's ViewFilterRule refuses — the builder's own (string | number | boolean)[] list member outlives #26306 #26348 (it reaches plugin-list UserFilters).
  • Serial constraints. components record: this PR goes first. fix(types,components)!: renderer props index signatures map to unknown, not any #26293 (held) and feat(types,react,sdui-parser)!: retire the node-level visibleOn / hiddenOn / disabledOn spellings #26274 (domain:spec#2, draft) merge main and re-record after it. The ⛔ paths are untouched.

Landing: ready + auto-merge through the relay.

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

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

2 participants