Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/record-picker-filter-rule-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
---

feat(spec)!: `ComponentPropsMap['element:record_picker'].filter` converges onto the `ViewFilterRule` array form — the last record-form `filter` in the map (#14406, objectui#6206 Option B)

<!-- adr-0087: registered element-record-picker-filter-rule-array -->

**BREAKING** accept-set change on one props-map entry, shipped as `minor` under
the repo's launch-window convention for breaking changes; the migration
prescription is registered under protocol major 18.

One filter orthography platform-wide (maintainer batch adjudication 2026-08-25,
verbatim 「同意」, Option B): after `element:number` converged (#12039 Key 2),
`element:record_picker`'s `filter` was the one `filter` input in
`ComponentPropsMap` still declared as the MongoDB-style record
(`FilterConditionSchema`) while the three array-declared siblings
(`record:related_list`, its nested Add-affordance picker, `element:number`)
declared `z.array(ViewFilterRuleSchema)` — the four `object-*` doors declare
`filter` as `z.unknown()`, #15449 — so the filter a list view stores and
renders was refused by the picker beside it. The entry now declares the same
array form those siblings do, and the `FilterConditionSchema` import that existed for this
one site leaves the file with it.

Sequenced measurement-first, as that convergence had to be: the `record_picker`
read path was measured at the objectui pin before the declaration moved. The
renderer hands `filter` to `query.$filter` and calls `adapter.find()`, whose
`convertQueryParams` lowers a rule array through `translateFilterArray` into
filter AST tuples — the door every list view's stored rule array already takes
— and nothing on that path parses `properties` against the installed spec.

**Migration** (`element-record-picker-filter-rule-array` — listed by
`os migrate meta --from 17` once the protocol major is 18): a record-form `filter: { status: 'active' }` becomes
`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an operator
object `{ amount: { $gt: 100 } }` becomes
`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys
become several rules (they AND). The record form is refused at `filter`
(`invalid_type`, expected array). The binding-level `dataSource.filter` on the
same node is a different key and is unchanged by this release.

`ElementRecordPickerPropsParsed` is declared (ADR-0122): the entry's parsed
state now differs from its authored state on `filter` (`operator` normalizes on
parse), so the bare alias is no longer isomorphic.
12 changes: 11 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ View filter rule
| **labelField** | `string` | optional | Field rendered as each row's text (default `name`) |
| **valueField** | `string` | optional | Field whose value is written into the bound page variable (default `id`) |
| **label** | `string \| Record<string, string>` | optional | Control label rendered above the select |
| **filter** | `any` | optional | Filter criteria for available records |
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set |
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set |
| **limit** | `integer` | optional | Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50) |
| **targetVariable** | `never` | optional | [REMOVED] `element:record_picker` property `targetVariable` was removed in @objectstack/spec 17 (ADR-0049) — it was a declarative hint no renderer ever read: the live binding runs the other direction, resolved from the page variable whose `source` names this component's `id`, so authoring only `targetVariable` bound nothing while reporting success. Delete the key; to bind the picked record id, declare it on the variable — `variables: [{ name: '<var>', type: 'record_id', source: '<this component id>' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
Expand All @@ -223,6 +223,16 @@ View filter rule
| **multiple** | `never` | optional | [REMOVED] `element:record_picker` property `multiple` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker is a single-select `Select` and the bound page variable holds one record id, so `multiple: true` selected nothing extra and reported success. Delete the key; multi-record selection is not implemented on this element. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

### Nested Shape: `ElementRecordPickerProps.filter[number]`

View filter rule

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field name to filter on |
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. |

### Nested Shape: `ElementRecordPickerProps.sort[number]`

Sort field and direction pair
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"ElementNumberPropsParsed (type)",
"ElementNumberPropsSchema (const)",
"ElementRecordPickerProps (type)",
"ElementRecordPickerPropsParsed (type)",
"ElementRecordPickerPropsSchema (const)",
"ElementTextInputPropsSchema (const)",
"ElementTextPropsSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)",
"ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)",
"ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)",
"ElementRecordPickerPropsParsed": "src/ui/component.zod.ts#ElementRecordPickerPropsParsed (type)",
"ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)",
"ElementTextInputPropsSchema": "src/ui/component.zod.ts#ElementTextInputPropsSchema (const)",
"ElementTextPropsSchema": "src/ui/component.zod.ts#ElementTextPropsSchema (const)",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'element-record-picker-filter-rule-array',
surface:
"`element:record_picker` component props — `filter` (the FORM: the MongoDB-style "
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
replacement:
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
+ "the map's array-declared `filter` doors already carry (`record:related_list`, its nested "
+ 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as '
+ '`z.unknown()`, #15449). A record-form filter '
+ "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; "
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
+ 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node '
+ 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry',
reason:
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` "
+ 'was the LAST `filter` input in the map still declared as the MongoDB-style record '
+ '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three '
+ 'array-declared doors (`record:related_list`, its nested Add-affordance picker, '
+ '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors '
+ 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused '
+ 'by the picker beside them, and a lone holdout is the state where the next author copies '
+ 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 '
+ 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands '
+ '`filter` to `query.$filter` and calls `adapter.find()` '
+ '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` '
+ 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples '
+ '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array '
+ 'already takes, and the engine lowers the tuples before the driver '
+ '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` '
+ 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on '
+ 'every read-path file. The ruled migration check ran with the change: the sweep of '
+ 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) '
+ 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test '
+ 'fixture, rewritten to the array form in the same change — and zero outside the spec '
+ 'package; this entry carries the prescription for authors outside the repo.',
acceptanceCriteria:
"`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: "
+ "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is "
+ "the same rule array; a record-form `filter: { status: 'active' }` is refused at the "
+ '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the '
+ 'rows the array selects — the same filter a list view renders. Downstream (objectui, after '
+ "a released spec version reaches the pin): the registry's `inputs.filter` entry for "
+ "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and "
+ 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — '
+ 'objectui#7663, filed from #14406 with a Blocked-by line.',
};
50 changes: 50 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6415,6 +6415,56 @@ const step18: MigrationStep = {
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
+ 'objectui#6206.',
},
{
id: 'element-record-picker-filter-rule-array',
surface:
"`element:record_picker` component props — `filter` (the FORM: the MongoDB-style "
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
replacement:
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
+ "the map's array-declared `filter` doors already carry (`record:related_list`, its nested "
+ 'Add-affordance picker, `element:number`; the four `object-*` blocks declare `filter` as '
+ '`z.unknown()`, #15449). A record-form filter '
+ "`{ status: 'active' }` becomes `[{ field: 'status', operator: 'equals', value: 'active' }]`; "
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
+ 'accepted and normalized on parse. The binding-level `dataSource.filter` on the same node '
+ 'is a different key (`ElementDataSourceSchema`) and is not moved by this entry',
reason:
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:record_picker'].filter` "
+ 'was the LAST `filter` input in the map still declared as the MongoDB-style record '
+ '(`FilterConditionSchema`) after `element:number` converged (#12039 Key 2): the three '
+ 'array-declared doors (`record:related_list`, its nested Add-affordance picker, '
+ '`element:number`) carried the `ViewFilterRule` array and the four `object-*` doors '
+ 'declare `z.unknown()` (#15449), so the filter a list view stores and renders was refused '
+ 'by the picker beside them, and a lone holdout is the state where the next author copies '
+ 'the wrong form. Sequenced measurement-first, as that convergence had to be (the 2026-08-25 '
+ 'Option-A ordering ruling, #14406): at the objectui pin `00d3f09c` the renderer hands '
+ '`filter` to `query.$filter` and calls `adapter.find()` '
+ '(`components/src/renderers/basic/record-picker.tsx`); `ObjectStackAdapter.convertQueryParams` '
+ 'lowers an ARRAY `$filter` through `translateFilterArray` into filter AST tuples '
+ '(`data-objectstack/src/index.ts`), the same door every list view\'s stored rule array '
+ 'already takes, and the engine lowers the tuples before the driver '
+ '(`engine-filter-array-lowering.test.ts`); nothing on that path parses `properties` '
+ 'against the installed spec. The pin and objectui `main` (`f7cf7e8`) are byte-identical on '
+ 'every read-path file. The ruled migration check ran with the change: the sweep of '
+ 'first-party corpora (examples/, skills/, content/docs/, docs/, packages/**, .changeset/) '
+ 'found ONE `element:record_picker` author writing a record-form `filter` — a spec test '
+ 'fixture, rewritten to the array form in the same change — and zero outside the spec '
+ 'package; this entry carries the prescription for authors outside the repo.',
acceptanceCriteria:
"`ComponentPropsMap['element:record_picker'].safeParse({ object, filter: [{ field: "
+ "'status', operator: 'equals', value: 'active' }] })` succeeds and the parsed `filter` is "
+ "the same rule array; a record-form `filter: { status: 'active' }` is refused at the "
+ '`filter` path (`invalid_type`, expected array). At runtime the picker offers exactly the '
+ 'rows the array selects — the same filter a list view renders. Downstream (objectui, after '
+ "a released spec version reaches the pin): the registry's `inputs.filter` entry for "
+ "`element:record_picker` (`type: 'object'`, `record-picker.tsx`) flips to the array arm and "
+ 'the `record-picker-inputs-spec-parity.test.ts` pins that assert the record form follow — '
+ 'objectui#7663, filed from #14406 with a Blocked-by line.',
},
{
id: 'engine-dotted-filter-refused',
surface:
Expand Down
21 changes: 17 additions & 4 deletions packages/spec/src/type-alias-convention.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ import type * as M183 from './api/sortability.zod.js';
import type * as M184 from './shared/value-domain.zod.js';

// ---------------------------------------------------------------------------
// 826 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
// 825 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
Expand Down Expand Up @@ -1557,7 +1557,10 @@ export type Iso817 = Assert<Eq< z.input< typeof M155.ActionType >, z.infer< type
// own input ≠ infer (`operator` is normalized on parse — `ViewFilterRuleParsed`
// exists for exactly that reason), so `ElementNumberPropsParsed` is declared
// and the pin deleted.
export type Iso819 = Assert<Eq< z.input< typeof M170.ElementRecordPickerPropsSchema >, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>;
// `ElementRecordPickerPropsSchema` (Iso819) left the family the same way on
// #14406 — the LAST record-form `filter` in `ComponentPropsMap`: its `filter`
// now carries `z.array(ViewFilterRuleSchema)` too, so `ElementRecordPickerPropsParsed`
// is declared and this pin deleted.
export type Iso820 = Assert<Eq< z.input< typeof M170.RecordHighlightsField >, z.infer< typeof M170.RecordHighlightsField > >>;
export type Iso821 = Assert<Eq< z.input< typeof M170.RecordPathProps >, z.infer< typeof M170.RecordPathProps > >>;
// `record:reference_rail` (#8691) — deliberately default-free on the same
Expand Down Expand Up @@ -1682,7 +1685,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
it('still declares all 826 isomorphic pins', () => {
it('still declares all 825 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert<Eq<...>>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
Expand Down Expand Up @@ -2133,7 +2136,17 @@ describe('ADR-0122 type-alias convention', () => {
// `ErrorMappingRuleSchema` and `ConnectorErrorCategorySchema` left whole
// with the key (whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]`), so the
// two pins that named them (`Iso381` / `Iso382`) leave with the schemas.
expect(pins).toHaveLength(826);
//
// 826 -> 825 is #14406's ui#6206-B convergence of the LAST record-form
// `filter` in `ComponentPropsMap`: `ElementRecordPickerPropsSchema.filter`
// now carries `z.array(ViewFilterRuleSchema)`, whose own input ≠ infer
// (`operator` is normalized on parse — `ViewFilterRuleParsed` exists for
// exactly that reason), so `element:record_picker` left the isomorphic
// family the way ADR-0122 prescribes and `element:number` did one entry
// earlier: `ElementRecordPickerPropsParsed` declared, the Iso819 pin
// deleted. -1 converted to an `XParsed` pair; the Iso number stays vacant
// (ids are claims about pins, not positions).
expect(pins).toHaveLength(825);

// The count is stated in PROSE twice as well — this case's title and the
// section header above the pin list — and until #6605 nothing read either
Expand Down
Loading
Loading