Skip to content

Commit 4792049

Browse files
os-billclaude
andauthored
feat(spec)!: dataSource.filter and the four object-* filter doors converge onto the ViewFilterRule array form — one filter orthography reaches the family (objectui#6206-B, option A) (#17257)
* wip(spec): converge dataSource.filter and the four object-* filter doors onto the ViewFilterRule array Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * wip(spec): regenerate api-surface, export-origins and docs references for the filter converge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * wip(spec): migrate the two object-* fixtures in component.test.ts; ask each key at its own door in the binding pins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * fix(spec,lint): state the at-rest disposition on the D3 entry; restore the deleted `pages` pin Contract review findings F1 and F3 on this PR. F1 — the D3 entry stated the sweep and the objectui route but was silent on metadata AT REST, the one population its TODO cannot reach: no author runs `os migrate meta` over `sys_metadata`. Measured, all three legs: a `SemanticMigration` converts nothing by its own type (`migrations/types.ts`), this disposition adds no D2 conversion (`packages/spec/src/conversions/` is untouched by this branch), and `applyConversionsToStoredItem` (`database-loader.ts:831`) replays the full chain without validating, by its own contract. So a stored row carrying the record form keeps loading, is still rendered by objectui at the pin, and is refused only on its next save. One sentence added to `reason`, on the register of `17.view-filter-rule-value-shaped-by-operator.ts:38-47`, which states the same disposition for its own family. `registry.ts` follows by `gen:migration-registry` — that one sentence is its whole delta. F2 and the partial-D2 direction question are deliberately NOT in this commit: F2 is family-wide (it also reaches `element:number` and `element:record_picker`) and the D2 direction is the ruler's, not a dev's. The new sentence therefore states that this disposition adds no D2 conversion, and does not claim none is possible. F3 — this branch had dropped the only `pages` row from the "covers ..." case while `pages` remains in `EMPTY_COMBINATOR_SURFACES`, leaving that surface declared and unmeasured. Restored byte-identical to the base tree, plus a comment recording that the row authors the retired record form on purpose: this rule never parses a fixture against the spec schema — `walkAuthoredFilters` visits any `FILTER_KEYS` key it reaches, whatever its shape — so an author still writing the record form is judged here and gets a located error beside the schema refusal. Ablation: dropping `{ key: 'pages', kind: 'page' }` from `EMPTY_COMBINATOR_SURFACES` turns the case red losing exactly `page "home"` (21 passed -> 1 failed | 20 passed); the rule file was restored to its HEAD blob and verified by `git hash-object`. No generated artefact moved beyond `registry.ts`: `check:generated` reports all 15 up to date, and `spec-changes.json` / the upgrade guide correctly show no diff because both projections fold only to the current PROTOCOL_MAJOR (17) and this is a major-18 entry — measured against a lit control (the major-17 entry `view-filter-rule-value-shaped-by-operator` is present in both; the neighbouring major-18 `ui-record-blocks-unknown-keys-refused` is absent from both). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * fix(spec): correct the at-rest clause on the D3 filter entry — `--stored` does exist The entry said "no author runs `os migrate meta` over `sys_metadata`". That is false: `os migrate meta --stored` has exactly that subject (packages/cli/src/commands/migrate/ meta.ts — module doc "`--stored`: the same chain, over data at rest", the flag, `runStored`, `protocol.migrateStoredMetadata(...)`). The true and narrower fact the clause was reaching for: the stored pass replays ADR-0087 D2 conversions only — `migrateStoredMetadata` converts through `applyConversionsToStoredItem`, i.e. `applyConversions`, and emits no D3 TODOs (`todos` is 7x in the authored-mode half of meta.ts and 0x from `runStored` to EOF) — so with no D2 conversion added by this disposition it has nothing to rewrite for this shape. The scoped "this disposition adds no D2 conversion" is unchanged and stays scoped: the partial-D2 direction question is the ruler's, not this entry's. `registry.ts` follows by `gen:migration-registry`; no other sentence of the entry moved. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(spec): scope the at-rest stored-pass clause to this shape, not to protocol 18 `conversionIds` on step 18 is NOT empty — it carries the major's other D2 conversions — so "nothing to rewrite here" could be read as "the stored pass rewrites nothing at protocol 18", which is false. The true scope is this entry's disposition, which adds no D2 conversion of its own (its id is 0x in `conversions/registry.ts`, control `action-execute-to-target` lit at 1x). `registry.ts` follows by `gen:migration-registry`. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2509d7 commit 4792049

17 files changed

Lines changed: 678 additions & 43 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: the binding-level `dataSource.filter` and the four `object-*` `filter` doors converge onto the `ViewFilterRule` array form — one filter orthography platform-wide reaches the family (#15442, #15449; objectui#6206-B, decision batch #55 option A)
6+
7+
<!-- adr-0087: registered element-data-source-and-object-block-filter-rule-array -->
8+
9+
**BREAKING** accept-set change at five doors — `ElementDataSourceSchema.filter`
10+
(the `dataSource` binding every data-bound page component carries) and
11+
`ComponentPropsMap['object-grid' | 'object-metric' | 'object-kanban' |
12+
'object-calendar'].filter` — shipped as `minor` under the repo's launch-window
13+
convention for breaking changes; the migration prescription is registered under
14+
protocol major 18 as ONE entry for the family.
15+
16+
One filter orthography platform-wide (maintainer batch adjudication 2026-08-25,
17+
verbatim 「同意」; reached these two locations on 2026-09-06, decision batch #55,
18+
verbatim 「同意」, option A: converge family-wide). Until this release the
19+
binding alone declared the MongoDB-style record (`FilterConditionSchema`) — so it
20+
refused the array the consumer's own pins author at that key, and
21+
`element:record_picker` carried two orthographies at two keys resolved through
22+
one `??` in the renderer — while the four `object-*` doors declared `z.unknown()`
23+
and took the record, the ObjectQL AST tuple array and the rule array alike,
24+
silently. All five now declare `z.array(ViewFilterRuleSchema)`, the form every
25+
other `filter` door in the map already carried; the `FilterConditionSchema`
26+
import that existed in `page.zod.ts` for this one site leaves with it.
27+
28+
Sequenced measurement-first, as the family had to be: at the objectui pin
29+
`a472b07` the `object-metric` aggregate path posted an array `where` that
30+
`POST /analytics/query` refused (400 on every array form, #15828), so the
31+
converge was parked behind the pin bump #16626. At the pin this repo builds
32+
against (`53ded82b`, objectui#7754) the adapter lowers an authored array through
33+
`translateFilterArray` and the spec's own `parseFilterAST` sink before the
34+
wire; `ObjectGrid` lowers a rule array through `toFilterNode`; `ObjectKanban` /
35+
`ObjectCalendar` hand it verbatim to `$filter`, where `convertQueryParams`
36+
lowers it; the binding's composition seam AND-combines it with the named view's
37+
rules through `mergeFilterNodes`. Nothing on those paths parses the value
38+
against the installed spec.
39+
40+
**Migration** (`element-data-source-and-object-block-filter-rule-array`
41+
listed by `os migrate meta --from 17` once the protocol major is 18): a
42+
record-form `filter: { status: 'active' }` becomes
43+
`filter: [{ field: 'status', operator: 'equals', value: 'active' }]`; an
44+
operator object `{ status: { $ne: 'done' } }` becomes
45+
`[{ field: 'status', operator: 'not_equals', value: 'done' }]`; several keys
46+
become several rules (they AND); an AST tuple array
47+
`[['owner_id', '=', '{current_user_id}']]` becomes
48+
`[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]`
49+
placeholders and date macros are unchanged. The record form is refused at
50+
`filter` (`invalid_type`, expected array); the tuple array is refused at
51+
`filter.0` (expected object). The dashboard widget `filter`
52+
(`dashboard.zod.ts`) is a different family and is unchanged by this release
53+
(#15829); `object-grid.defaultFilters` is a different key, not named by the
54+
ruling, and is unchanged.
55+
56+
In-repo authors migrated in the same change: four spec test fixtures at the
57+
binding, five showcase authors (`my-work.page.ts`, `index.ts`) and three lint
58+
fixtures. Type aliases: `ElementDataSourceParsed`, `ObjectMetricPropsParsed`,
59+
`ObjectKanbanPropsParsed` and `ObjectCalendarPropsParsed` are declared (ADR-0122:
60+
`operator` normalizes on parse, so input ≠ infer at these five schemas now).

content/docs/references/ui/component.mdx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,13 +311,23 @@ Sort field and direction pair
311311
| **objectName** | `string` | optional | Object this calendar binds to. Optional because the component-level `dataSource` binding can supply the object instead |
312312
| **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` |
313313
| **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode |
314-
| **filter** | `any` | optional | Base query filter |
314+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | 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` |
315315
| **sort** | `any` | optional | Sort for the fetched events |
316316
| **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch |
317317
| **staticData** | `any[]` | optional | Static inline records |
318318
| **locale** | `string` | optional | Locale override for the calendar chrome |
319319
| **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) |
320320

321+
### Nested Shape: `ObjectCalendarProps.filter[number]`
322+
323+
View filter rule
324+
325+
| Property | Type | Required | Description |
326+
| :--- | :--- | :--- | :--- |
327+
| **field** | `string` || Field name to filter on |
328+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
329+
| **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. |
330+
321331

322332
---
323333

@@ -381,7 +391,7 @@ Sort field and direction pair
381391
| **title** | `string \| Record<string, string>` | optional | Fallback for `label` (the renderer reads `label \|\| title`) |
382392
| **columns** | `any[]` | optional | Columns: field names or column definition objects |
383393
| **fields** | `any[]` | optional | Field list fallback used when `columns` is absent |
384-
| **filter** | `any` | optional | Base query filter (ObjectQL filter array/AST) — lowered to the wire `$filter`. THE key, singular — not the plural misspelling |
394+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | 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` |
385395
| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` |
386396
| **sort** | `any` | optional | Initial sort (array of `{ field, order }`) |
387397
| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
@@ -414,6 +424,16 @@ Sort field and direction pair
414424
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source binding (ViewDataSchema — discriminated on `provider`: object \| api \| value \| schema). Static inline rows live at `{ provider: 'value', items: [...] }`; the bare-array shortcut is refused — see migration `object-grid-data-view-data-converged` |
415425
| **staticData** | `any[]` | optional | Deprecated bare-array static-rows shortcut the renderer still reads. Prefer `data: { provider: 'value', items: [...] }` |
416426

427+
### Nested Shape: `ObjectGridProps.filter[number]`
428+
429+
View filter rule
430+
431+
| Property | Type | Required | Description |
432+
| :--- | :--- | :--- | :--- |
433+
| **field** | `string` || Field name to filter on |
434+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
435+
| **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. |
436+
417437
### Nested Shape: `ObjectGridProps.data[provider='object']`
418438

419439
| Property | Type | Required | Description |
@@ -456,7 +476,7 @@ Sort field and direction pair
456476
| **objectName** | `string` | optional | Object this board binds to. Optional because the component-level `dataSource` binding can supply the object instead |
457477
| **groupBy** | `string` | optional | Field whose values become the board columns |
458478
| **columns** | `any[]` | optional | Swimlane definitions (`{ id, title }` per `groupBy` value, or bare value strings) — NOT a field projection |
459-
| **filter** | `any` | optional | Base query filter, handed to the wire `$filter` |
479+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | 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` |
460480
| **limit** | `integer` | optional | Maximum number of records loaded onto the board (row cap); lowered to the query's top-level `$top` (renderer default 100). The component-level `dataSource.limit` wins when both are set; a bound view's `pagination.pageSize` fills it only when unset |
461481
| **data** | `any[]` | optional | Static inline cards — bypasses the object query |
462482
| **cardTitle** | `string` | optional | Field rendered as each card title |
@@ -468,6 +488,16 @@ Sort field and direction pair
468488
| **coverImageField** | `string` | optional | Image field rendered as the card cover |
469489
| **conditionalFormatting** | `any` | optional | Card conditional formatting rules |
470490

491+
### Nested Shape: `ObjectKanbanProps.filter[number]`
492+
493+
View filter rule
494+
495+
| Property | Type | Required | Description |
496+
| :--- | :--- | :--- | :--- |
497+
| **field** | `string` || Field name to filter on |
498+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
499+
| **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. |
500+
471501

472502
---
473503

@@ -508,7 +538,7 @@ Sort field and direction pair
508538
| **icon** | `string` | optional | Lucide icon name drawn in the metric tile header, inside the `colorVariant`-tinted square. Read on this component — `ObjectMetricWidget` forwards it to `MetricWidget`, which resolves it with `getLazyIcon` (the `LazyIcon` module: kebab-case or PascalCase, degrading to the `Database` glyph on an unknown name). |
509539
| **colorVariant** | `Enum<'default' \| 'blue' \| 'teal' \| 'orange' \| 'purple' \| 'success' \| 'warning' \| 'danger'>` | optional | Icon container color variant |
510540
| **aggregate** | `any` | optional | Aggregation config (`{ field, function, groupBy? }`) run against the object |
511-
| **filter** | `any` | optional | Filter the aggregation is scoped by |
541+
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | 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` |
512542
| **format** | `string` | optional | Number format pattern (e.g. '0,0', '$0,0', '0%') |
513543
| **currency** | `string` | optional | ISO currency code (e.g. 'USD') — enables currency formatting |
514544
| **prefix** | `string` | optional | Static prefix before the formatted value |
@@ -520,6 +550,16 @@ Sort field and direction pair
520550
| **drillDown** | `any` | optional | Click-through drill config — opens the underlying records |
521551
| **compareTo** | `any` | optional | Period-over-period comparison (`{ kind: 'previousPeriod' \| 'previousYear' }`) |
522552

553+
### Nested Shape: `ObjectMetricProps.filter[number]`
554+
555+
View filter rule
556+
557+
| Property | Type | Required | Description |
558+
| :--- | :--- | :--- | :--- |
559+
| **field** | `string` || Field name to filter on |
560+
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` || Filter operator |
561+
| **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. |
562+
523563

524564
---
525565

0 commit comments

Comments
 (0)