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
46 changes: 46 additions & 0 deletions .changeset/element-number-filter-rule-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
"@objectstack/spec": minor
---

feat(spec): `ComponentPropsMap['element:number'].filter` converges onto the `ViewFilterRule` array form (#12039, objectui#6206 Option B)

<!-- adr-0087: registered element-number-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): `element:number`'s `filter` was the
one `filter` input in `ComponentPropsMap` declared as the MongoDB-style record
(`FilterConditionSchema`) while every sibling (`record:related_list` and its
Add-affordance picker) declared `z.array(ViewFilterRuleSchema)` — so the
filter a list view stores and renders was refused by the KPI element beside
it. The entry now declares the same array form its siblings do.

Sequenced consumer-first (the 2026-08-25 Option-A ordering ruling):
objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through
the same `translateFilterArray` its `find()` path runs before the analytics
wire; the objectui pin carrying it (`d8ec8d6d`) was re-measured before this
declaration moved, and the end-to-end chain is authored array → adapter
lowering → filter AST → accepted by `lowerAnalyticsWhere` (which still refuses
a RAW rule-object array — by design, and unchanged here).

Migration — FROM → TO, one rule per key (several keys AND):

```ts
// before (refused now — `invalid_type` at `filter`, expected array)
filter: { status: 'won' }
filter: { amount: { $gt: 100 } }
// after
filter: [{ field: 'status', operator: 'equals', value: 'won' }]
filter: [{ field: 'amount', operator: 'greater_than', value: 100 }]
```

The ruled migration check ran with the change: the sweep of first-party
corpora (examples/, skills/, create-objectstack, content/docs/, packages/apps/,
spec fixtures) found one `element:number` author writing a record-form
`filter` — a spec test fixture, rewritten to the array form here — and zero
outside the spec package. `ElementNumberPropsParsed` is declared
(ADR-0122: the parsed state now differs on `filter`, whose `operator` is
normalized on parse). `element:record_picker.filter` is not changed here.
12 changes: 11 additions & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,22 @@ const result = AIChatWindowProps.parse(data);
| **object** | `string` | ✅ | Source object |
| **field** | `string` | optional | Field to aggregate |
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function |
| **filter** | `any` | optional | Filter criteria |
| **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` |
| **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format |
| **prefix** | `string` | optional | Prefix text (e.g. "$") |
| **suffix** | `string` | optional | Suffix text (e.g. "%") |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

### Nested Shape: `ElementNumberProps.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: `ElementNumberProps.aria`

| Property | Type | Required | Description |
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 @@ -136,6 +136,7 @@
"ElementImagePropsSchema (const)",
"ElementMetadataViewerPropsSchema (const)",
"ElementNumberProps (type)",
"ElementNumberPropsParsed (type)",
"ElementNumberPropsSchema (const)",
"ElementRecordPickerProps (type)",
"ElementRecordPickerPropsSchema (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 @@ -136,6 +136,7 @@
"ElementImagePropsSchema": "src/ui/component.zod.ts#ElementImagePropsSchema (const)",
"ElementMetadataViewerPropsSchema": "src/ui/component.zod.ts#ElementMetadataViewerPropsSchema (const)",
"ElementNumberProps": "src/ui/component.zod.ts#ElementNumberProps (type)",
"ElementNumberPropsParsed": "src/ui/component.zod.ts#ElementNumberPropsParsed (type)",
"ElementNumberPropsSchema": "src/ui/component.zod.ts#ElementNumberPropsSchema (const)",
"ElementRecordPickerProps": "src/ui/component.zod.ts#ElementRecordPickerProps (type)",
"ElementRecordPickerPropsSchema": "src/ui/component.zod.ts#ElementRecordPickerPropsSchema (const)",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

export const entry: SemanticMigration = {
id: 'element-number-filter-rule-array',
surface:
"`element:number` component props — `filter` (the FORM: the MongoDB-style "
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
replacement:
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
+ 'every other `filter` input in `ComponentPropsMap` already declares '
+ '(`record:related_list` and its Add-affordance picker). A record-form filter '
+ "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; "
+ "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',
reason:
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` "
+ 'was the one `filter` input in the map declared as the MongoDB-style record '
+ '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so '
+ 'the filter a list view stores and renders was refused by the KPI element beside it, '
+ 'and the objectui parity gate had to carry a reasoned exemption to look away. The '
+ 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): '
+ 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the '
+ 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the '
+ 'objectui pin carrying it was re-measured before this entry moved — authored array → '
+ 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses '
+ 'a RAW rule-object array by design. The ruled migration check ran with the change: the '
+ 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, '
+ 'packages/apps/, spec fixtures) found ONE `element:number` 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:number'].safeParse({ object, aggregate, filter: [{ field: "
+ "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is "
+ "the same rule array; a record-form `filter: { status: 'won' }` is refused at the "
+ '`filter` path (`invalid_type`, expected array). At runtime the element renders its '
+ 'aggregate on an analytics-capable deployment with the array filter applied — the same '
+ 'filter a list view renders. Downstream (objectui, after a released spec version reaches '
+ "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` "
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
+ 'objectui#6206.',
};
43 changes: 43 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6194,6 +6194,49 @@ const step18: MigrationStep = {
+ 'correct but whose other columns belong to a different record, since no error was ever '
+ 'raised for those writes.',
},
{
id: 'element-number-filter-rule-array',
surface:
"`element:number` component props — `filter` (the FORM: the MongoDB-style "
+ '`FilterConditionSchema` record vs the `ViewFilterRule` array)',
replacement:
'`z.array(ViewFilterRuleSchema)` — the rule array `[{ field, operator, value }, ...]` '
+ 'every other `filter` input in `ComponentPropsMap` already declares '
+ '(`record:related_list` and its Add-affordance picker). A record-form filter '
+ "`{ status: 'won' }` becomes `[{ field: 'status', operator: 'equals', value: 'won' }]`; "
+ "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',
reason:
'One filter orthography platform-wide (objectui#6206, maintainer batch adjudication '
+ "2026-08-25, verbatim 「同意」, Option B). `ComponentPropsMap['element:number'].filter` "
+ 'was the one `filter` input in the map declared as the MongoDB-style record '
+ '(`FilterConditionSchema`) while its siblings declared the `ViewFilterRule` array, so '
+ 'the filter a list view stores and renders was refused by the KPI element beside it, '
+ 'and the objectui parity gate had to carry a reasoned exemption to look away. The '
+ 'convergence was sequenced consumer-first (ruling recorded 2026-08-25, Option A): '
+ 'objectui#6828 made `ObjectStackAdapter.aggregate()` lower a rule array through the '
+ 'same `translateFilterArray` its `find()` path runs before the analytics wire, and the '
+ 'objectui pin carrying it was re-measured before this entry moved — authored array → '
+ 'adapter lowering → filter AST → accepted by `lowerAnalyticsWhere`, which still refuses '
+ 'a RAW rule-object array by design. The ruled migration check ran with the change: the '
+ 'sweep of first-party corpora (examples/, skills/, create-objectstack, content/docs/, '
+ 'packages/apps/, spec fixtures) found ONE `element:number` 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:number'].safeParse({ object, aggregate, filter: [{ field: "
+ "'status', operator: 'equals', value: 'won' }] })` succeeds and the parsed `filter` is "
+ "the same rule array; a record-form `filter: { status: 'won' }` is refused at the "
+ '`filter` path (`invalid_type`, expected array). At runtime the element renders its '
+ 'aggregate on an analytics-capable deployment with the array filter applied — the same '
+ 'filter a list view renders. Downstream (objectui, after a released spec version reaches '
+ "the pin): the `element:number.filter:array` entry in `OFF_SPEC_ARM_EXEMPTIONS` "
+ '(`registry-inputs-spec-parity.test.ts`) becomes deletable, which is what closes '
+ 'objectui#6206.',
},
{
id: 'engine-dotted-filter-refused',
surface:
Expand Down
20 changes: 16 additions & 4 deletions packages/spec/src/type-alias-convention.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js';
import type * as M183 from './api/sortability.zod.js';

// ---------------------------------------------------------------------------
// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
// 835 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 @@ -1556,7 +1556,11 @@ export type Iso816 = Assert<Eq< z.input< typeof M152.LocaleSchema >, z.infer< ty
export type Iso817 = Assert<Eq< z.input< typeof M155.ActionType >, z.infer< typeof M155.ActionType > >>;

// ui/component.zod.ts
export type Iso818 = Assert<Eq< z.input< typeof M170.ElementNumberPropsSchema >, z.infer< typeof M170.ElementNumberPropsSchema > >>;
// `ElementNumberPropsSchema` (Iso818) left the family on the ui#6206
// convergence: its `filter` now carries `z.array(ViewFilterRuleSchema)`, whose
// 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 > >>;
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 > >>;
Expand Down Expand Up @@ -1682,7 +1686,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 836 isomorphic pins', () => {
it('still declares all 835 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 @@ -2068,9 +2072,17 @@ describe('ADR-0122 type-alias convention', () => {
// `.pipe()` anywhere, so the two shapes coincide and ADR-0122 gives it a
// pin rather than an `XParsed`. Its id is `Iso866`, the next free one —
// ids are claims about pins, not positions.
//
// 836 -> 835 is #12039's ui#6206 convergence (the card's Key 2):
// `ElementNumberPropsSchema.filter` now carries `z.array(ViewFilterRuleSchema)`,
// whose own input ≠ infer (`operator` is normalized on parse — measured:
// `ViewFilterRuleParsed` already exists for exactly that reason), so
// `element:number` left the isomorphic family the way ADR-0122 prescribes:
// `ElementNumberPropsParsed` declared, the Iso818 pin deleted. -1 converted
// to an `XParsed` pair; the Iso number stays vacant.
const self = readFileSync(fileURLToPath(import.meta.url), 'utf8');
const pins = self.match(/^export type Iso\d+ = Assert</gm) ?? [];
expect(pins).toHaveLength(836);
expect(pins).toHaveLength(835);

// 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