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
58 changes: 58 additions & 0 deletions .changeset/17320-filter-rule-array-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
'@objectstack/spec': patch
---

The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form

Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the
objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and
the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` /
`element:number` / `element:record_picker` rows of `ComponentPropsMap`
(`ui/component.zod.ts`). Each previously accepted the MongoDB-style record
(`{ status: 'active' }`), and each now refuses it — measured on the built
artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid
input: expected array, received object"*, and nothing else.

The prescription for that transition was already written down twice, in two
places a parse never reaches: every one of the seven `.describe()` strings, and
in full in the three `18.*-filter-rule-array` semantic migration entries.
Nothing bridges `.describe()` into a zod issue and this package installs no
global error map, so the one population whose metadata the convergence broke —
the authors, human and AI, who wrote the previously-legal form — received the
single sentence that does not say what to write instead.

Each of the seven now answers that value with the new spelling, through the
zod-v4 `{ error }` param this package already uses for targeted guidance
(`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`):

> `filter` on this `object-grid` takes the ViewFilterRule ARRAY form
> `[{ field, operator, value }, ...]`, and this value is the MongoDB-style
> record form this door took before the one-filter-orthography convergence.
> Write one rule per record key — they AND — so this filter becomes
> `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator
> shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse.
> Full conversion table: migration
> `element-data-source-and-object-block-filter-rule-array`.

Following `strictObject`'s model rather than transcribing a sentence seven
times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the
canonical operator is `normalizeFilterOperator('eq')` — the same fold the door
itself runs — and the worked rewrite is computed from the author's own record,
so the example names their fields. A pin holds each door's `migration` id equal
to a real registry entry and each door's `surface` equal to the one its own
`strictObject` declaration registered.

⛔ No accept set moves. The doors refuse exactly the shapes they refused
before, the generated `json-schema/` and `authorable-surface` artifacts are
byte-identical after the change, and the map returns `undefined` for everything
that is not a plain record — so an array author's element-level issues
(`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value
(*"expected array, received string"*) still arrive in zod's own words.

**Shipped, which is why it carries a changeset rather than `skip-changeset`.**
Measured on the built artifact after both tsup passes finished: the new message
text is present in **18** published files of `npm pack --dry-run`'s 2012, the
test-only text is present in **0** (negative control), and a pre-existing
shipped string reaches **62** as the lit control proving the scan reaches.
`src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source
by `files[]`'s `src/**/*.zod.ts`.
43 changes: 37 additions & 6 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod';
// shared source rather than re-spelled here (#6276).
import { SortItemSchema } from '../shared/enums.zod';
import { strictObject } from '../shared/strict-object';
import { ruleArrayFilterError } from './filter-rule-array';
import type { KeySetGuidance } from '../shared/suggestions.zod';
// [#13855] The section → field-group reference form, shared with
// `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches.
Expand Down Expand Up @@ -1850,7 +1851,12 @@ export const ElementNumberPropsSchema = lazySchema(() => strictObject({
* array, by design). The record form is refused at `filter`; the migration
* prescription is the `element-number-filter-rule-array` semantic entry.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `element:number`',
migration: 'element-number-filter-rule-array',
}),
}).optional()
.describe('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: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'),
prefix: z.string().optional().describe('Prefix text (e.g. "$")'),
Expand Down Expand Up @@ -2230,7 +2236,12 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({
* (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this
* entry's subject.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `element:record_picker`',
migration: 'element-record-picker-filter-rule-array',
}),
}).optional()
.describe('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'),
/**
* Row order (#6276). The flat shorthand for `dataSource.sort`, and the same
Expand Down Expand Up @@ -2491,7 +2502,12 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({
* `filter`; the migration prescription is the
* `element-data-source-and-object-block-filter-rule-array` semantic entry.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `object-grid`',
migration: 'element-data-source-and-object-block-filter-rule-array',
}),
}).optional()
.describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
defaultFilters: z.unknown().optional()
.describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'),
Expand Down Expand Up @@ -2705,7 +2721,12 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
* at `filter`; see migration
* `element-data-source-and-object-block-filter-rule-array`.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `object-metric`',
migration: 'element-data-source-and-object-block-filter-rule-array',
}),
}).optional()
.describe('Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"),
currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"),
Expand Down Expand Up @@ -2777,7 +2798,12 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({
* `filter`; see migration
* `element-data-source-and-object-block-filter-rule-array`.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `object-kanban`',
migration: 'element-data-source-and-object-block-filter-rule-array',
}),
}).optional()
.describe('Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
/**
* Row cap (#16503 — the spec half of objectui#8172; decision batch #68,
Expand Down Expand Up @@ -2928,7 +2954,12 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({
* `filter`; see migration
* `element-data-source-and-object-block-filter-rule-array`.
*/
filter: z.array(ViewFilterRuleSchema).optional()
filter: z.array(ViewFilterRuleSchema, {
error: ruleArrayFilterError({
surface: 'this `object-calendar`',
migration: 'element-data-source-and-object-block-filter-rule-array',
}),
}).optional()
.describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'),
/**
* Row order for the fetched events — the same `SortItem` ARRAY form
Expand Down
Loading
Loading