Skip to content

Commit 79b6a22

Browse files
claude[bot]claude
andauthored
feat(spec): converge ComponentPropsMap['element:number'].filter onto the ViewFilterRule array form (#12039, ui#6206 Option B) (#14404)
* feat(spec): converge ComponentPropsMap['element:number'].filter onto the ViewFilterRule array form (ui#6206 Option B) Accept-set change on one props-map entry: `filter` was `FilterConditionSchema` (the MongoDB-style record), now `z.array(ViewFilterRuleSchema)` — the one filter orthography every sibling `filter` input in the map already declares. Sequenced consumer-first per the 2026-08-25 Option-A ordering ruling: the objectui pin (d8ec8d6d) carries objectui#6828, re-measured before this move. ADR-0087 semantic entry `element-number-filter-rule-array` carries the prescription; `ElementNumberPropsParsed` declared and the Iso818 pin deleted with its receipt (ADR-0122); orthography tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * test(spec): rewrite the one in-repo record-form element:number filter fixture to the array orthography; regenerate api-surface, export-origins, docs The ruled migration sweep found exactly one first-party author of the record-form `filter` on `element:number` — `component.test.ts`'s "should accept full number props" fixture — and zero in examples/, skills/, create-objectstack, content/docs/, packages/apps/. The entry and changeset now say so. `check:generated --fix` proved three artifacts stale and regenerated them: api-surface and export-origins carry the new `ElementNumberPropsParsed` export, the docs reference carries the array shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41aa979 commit 79b6a22

9 files changed

Lines changed: 282 additions & 7 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ComponentPropsMap['element:number'].filter` converges onto the `ViewFilterRule` array form (#12039, objectui#6206 Option B)
6+
7+
<!-- adr-0087: registered element-number-filter-rule-array -->
8+
9+
**BREAKING** accept-set change on one props-map entry, shipped as `minor` under
10+
the repo's launch-window convention for breaking changes; the migration
11+
prescription is registered under protocol major 18.
12+
13+
One filter orthography platform-wide (maintainer batch adjudication
14+
2026-08-25, verbatim 「同意」, Option B): `element:number`'s `filter` was the
15+
one `filter` input in `ComponentPropsMap` declared as the MongoDB-style record
16+
(`FilterConditionSchema`) while every sibling (`record:related_list` and its
17+
Add-affordance picker) declared `z.array(ViewFilterRuleSchema)` — so the
18+
filter a list view stores and renders was refused by the KPI element beside
19+
it. The entry now declares the same array form its siblings do.
20+
21+
Sequenced consumer-first (the 2026-08-25 Option-A ordering ruling):
22+
objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through
23+
the same `translateFilterArray` its `find()` path runs before the analytics
24+
wire; the objectui pin carrying it (`d8ec8d6d`) was re-measured before this
25+
declaration moved, and the end-to-end chain is authored array → adapter
26+
lowering → filter AST → accepted by `lowerAnalyticsWhere` (which still refuses
27+
a RAW rule-object array — by design, and unchanged here).
28+
29+
Migration — FROM → TO, one rule per key (several keys AND):
30+
31+
```ts
32+
// before (refused now — `invalid_type` at `filter`, expected array)
33+
filter: { status: 'won' }
34+
filter: { amount: { $gt: 100 } }
35+
// after
36+
filter: [{ field: 'status', operator: 'equals', value: 'won' }]
37+
filter: [{ field: 'amount', operator: 'greater_than', value: 100 }]
38+
```
39+
40+
The ruled migration check ran with the change: the sweep of first-party
41+
corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/,
42+
spec fixtures) found one `element:number` author writing a record-form
43+
`filter` — a spec test fixture, rewritten to the array form here — and zero
44+
outside the spec package. `ElementNumberPropsParsed` is declared
45+
(ADR-0122: the parsed state now differs on `filter`, whose `operator` is
46+
normalized on parse). `element:record_picker.filter` is not changed here.

content/docs/references/ui/component.mdx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,22 @@ const result = AIChatWindowProps.parse(data);
175175
| **object** | `string` || Source object |
176176
| **field** | `string` | optional | Field to aggregate |
177177
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` || Aggregation function |
178-
| **filter** | `any` | optional | Filter criteria |
178+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array` |
179179
| **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format |
180180
| **prefix** | `string` | optional | Prefix text (e.g. "$") |
181181
| **suffix** | `string` | optional | Suffix text (e.g. "%") |
182182
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
183183

184+
### Nested Shape: `ElementNumberProps.filter[number]`
185+
186+
View filter rule
187+
188+
| Property | Type | Required | Description |
189+
| :--- | :--- | :--- | :--- |
190+
| **field** | `string` || Field name to filter on |
191+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
192+
| **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. |
193+
184194
### Nested Shape: `ElementNumberProps.aria`
185195

186196
| Property | Type | Required | Description |

packages/spec/api-surface/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
"ElementImagePropsSchema (const)",
137137
"ElementMetadataViewerPropsSchema (const)",
138138
"ElementNumberProps (type)",
139+
"ElementNumberPropsParsed (type)",
139140
"ElementNumberPropsSchema (const)",
140141
"ElementRecordPickerProps (type)",
141142
"ElementRecordPickerPropsSchema (const)",

packages/spec/export-origins/ui.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
"ElementImagePropsSchema": "src/ui/component.zod.ts#ElementImagePropsSchema (const)",
137137
"ElementMetadataViewerPropsSchema": "src/ui/component.zod.ts#ElementMetadataViewerPropsSchema (const)",
138138
"ElementNumberProps": "src/ui/component.zod.ts#ElementNumberProps (type)",
139+
"ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)",
139140
"ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)",
140141
"ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)",
141142
"ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'element-number-filter-rule-array',
7+
surface:
8+
"`element:number` component props — `filter` (the FORM: the MongoDB-style "
9+
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
10+
replacement:
11+
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
12+
+ 'every other `filter` input in `ComponentPropsMap` already declares '
13+
+ '(`record:related_list` and its Add-affordance picker). A record-form filter '
14+
+ "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; "
15+
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
16+
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
17+
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
18+
+ 'accepted and normalized on parse',
19+
reason:
20+
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
21+
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` "
22+
+ 'was the one `filter` input in the map declared as the MongoDB-style record '
23+
+ '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so '
24+
+ 'the filter a list view stores and renders was refused by the KPI element beside it, '
25+
+ 'and the objectui parity gate had to carry a reasoned exemption to look away. The '
26+
+ 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): '
27+
+ 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the '
28+
+ 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the '
29+
+ 'objectui pin carrying it was re-measured before this entry moved — authored array → '
30+
+ 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses '
31+
+ 'a RAW rule-object array by design. The ruled migration check ran with the change: the '
32+
+ 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, '
33+
+ 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form '
34+
+ '`filter` — a spec test fixture, rewritten to the array form in the same change — and '
35+
+ 'zero outside the spec package; this entry carries the prescription for authors outside '
36+
+ 'the repo.',
37+
acceptanceCriteria:
38+
"`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: "
39+
+ "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is "
40+
+ "the same rule array; a record-form `filter: { status: 'won' }` is refused at the "
41+
+ '`filter` path (`invalid_type`, expected array). At runtime the element renders its '
42+
+ 'aggregate on an analytics-capable deployment with the array filter applied — the same '
43+
+ 'filter a list view renders. Downstream (objectui, after a released spec version reaches '
44+
+ "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` "
45+
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
46+
+ 'objectui#6206.',
47+
};

packages/spec/src/migrations/registry.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6194,6 +6194,49 @@ const step18: MigrationStep = {
61946194
+ 'correct but whose other columns belong to a different record, since no error was ever '
61956195
+ 'raised for those writes.',
61966196
},
6197+
{
6198+
id: 'element-number-filter-rule-array',
6199+
surface:
6200+
"`element:number` component props — `filter` (the FORM: the MongoDB-style "
6201+
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
6202+
replacement:
6203+
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
6204+
+ 'every other `filter` input in `ComponentPropsMap` already declares '
6205+
+ '(`record:related_list` and its Add-affordance picker). A record-form filter '
6206+
+ "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; "
6207+
+ "an operator object `{ amount: { $gt: 100 } }` becomes "
6208+
+ "`[{ field: 'amount', operator: 'greater_than', value: 100 }]`; several keys become "
6209+
+ 'several rules (they AND). Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are '
6210+
+ 'accepted and normalized on parse',
6211+
reason:
6212+
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
6213+
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` "
6214+
+ 'was the one `filter` input in the map declared as the MongoDB-style record '
6215+
+ '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so '
6216+
+ 'the filter a list view stores and renders was refused by the KPI element beside it, '
6217+
+ 'and the objectui parity gate had to carry a reasoned exemption to look away. The '
6218+
+ 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): '
6219+
+ 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the '
6220+
+ 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the '
6221+
+ 'objectui pin carrying it was re-measured before this entry moved — authored array → '
6222+
+ 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses '
6223+
+ 'a RAW rule-object array by design. The ruled migration check ran with the change: the '
6224+
+ 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, '
6225+
+ 'packages/apps/, spec fixtures) found ONE `element:number` author writing a record-form '
6226+
+ '`filter` — a spec test fixture, rewritten to the array form in the same change — and '
6227+
+ 'zero outside the spec package; this entry carries the prescription for authors outside '
6228+
+ 'the repo.',
6229+
acceptanceCriteria:
6230+
"`ComponentPropsMap['element:number'].safeParse({ object, aggregate, filter: [{ field: "
6231+
+ "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is "
6232+
+ "the same rule array; a record-form `filter: { status: 'won' }` is refused at the "
6233+
+ '`filter` path (`invalid_type`, expected array). At runtime the element renders its '
6234+
+ 'aggregate on an analytics-capable deployment with the array filter applied — the same '
6235+
+ 'filter a list view renders. Downstream (objectui, after a released spec version reaches '
6236+
+ "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` "
6237+
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
6238+
+ 'objectui#6206.',
6239+
},
61976240
{
61986241
id: 'engine-dotted-filter-refused',
61996242
surface:

packages/spec/src/type-alias-convention.pin.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js';
269269
import type * as M183 from './api/sortability.zod.js';
270270

271271
// ---------------------------------------------------------------------------
272-
// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
272+
// 835 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
273273
//
274274
// That number is machine-checked, not hand-kept. The runtime companion at the
275275
// bottom of this file recomputes the pin count from the source and asserts that
@@ -1556,7 +1556,11 @@ export type Iso816 = Assert<Eq< z.input< typeof M152.LocaleSchema >, z.infer< ty
15561556
export type Iso817 = Assert<Eq< z.input< typeof M155.ActionType >, z.infer< typeof M155.ActionType > >>;
15571557

15581558
// ui/component.zod.ts
1559-
export type Iso818 = Assert<Eq< z.input< typeof M170.ElementNumberPropsSchema >, z.infer< typeof M170.ElementNumberPropsSchema > >>;
1559+
// `ElementNumberPropsSchema` (Iso818) left the family on the ui#6206
1560+
// convergence: its `filter` now carries `z.array(ViewFilterRuleSchema)`, whose
1561+
// own input ≠ infer (`operator` is normalized on parse — `ViewFilterRuleParsed`
1562+
// exists for exactly that reason), so `ElementNumberPropsParsed` is declared
1563+
// and the pin deleted.
15601564
export type Iso819 = Assert<Eq< z.input< typeof M170.ElementRecordPickerPropsSchema >, z.infer< typeof M170.ElementRecordPickerPropsSchema > >>;
15611565
export type Iso820 = Assert<Eq< z.input< typeof M170.RecordHighlightsField >, z.infer< typeof M170.RecordHighlightsField > >>;
15621566
export type Iso821 = Assert<Eq< z.input< typeof M170.RecordPathProps >, z.infer< typeof M170.RecordPathProps > >>;
@@ -1682,7 +1686,7 @@ describe('ADR-0122 type-alias convention', () => {
16821686
// this title and the section header above the pin list — are now asserted
16831687
// against the recomputed count below, so neither can go stale without a red
16841688
// test naming it.
1685-
it('still declares all 836 isomorphic pins', () => {
1689+
it('still declares all 835 isomorphic pins', () => {
16861690
// The truth of each pin is proved by tsc, not here — an `Assert<Eq<...>>`
16871691
// that stops holding is a compile error with the alias named. What tsc
16881692
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2068,9 +2072,17 @@ describe('ADR-0122 type-alias convention', () => {
20682072
// `.pipe()` anywhere, so the two shapes coincide and ADR-0122 gives it a
20692073
// pin rather than an `XParsed`. Its id is `Iso866`, the next free one —
20702074
// ids are claims about pins, not positions.
2075+
//
2076+
// 836 -> 835 is #12039's ui#6206 convergence (the card's Key 2):
2077+
// `ElementNumberPropsSchema.filter` now carries `z.array(ViewFilterRuleSchema)`,
2078+
// whose own input ≠ infer (`operator` is normalized on parse — measured:
2079+
// `ViewFilterRuleParsed` already exists for exactly that reason), so
2080+
// `element:number` left the isomorphic family the way ADR-0122 prescribes:
2081+
// `ElementNumberPropsParsed` declared, the Iso818 pin deleted. -1 converted
2082+
// to an `XParsed` pair; the Iso number stays vacant.
20712083
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
20722084
const pins = self.match(/^export type Iso\d+ = Assert</gm) ?? [];
2073-
expect(pins).toHaveLength(836);
2085+
expect(pins).toHaveLength(835);
20742086

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

0 commit comments

Comments
 (0)