From c2d781ab1ee2ebb38fbd92af9c617d65ca1dbe7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:16:55 +0000 Subject: [PATCH 01/14] wip(spec)!: retire the element-layer flat data-binding keys and object-grid defaultFilters (#11509) Tombstones, the two protocol-18 conversions, the registry entries and the absorbed step-18 narrowings. Tests and generated artifacts follow. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 816 ++++++++++++++++-- .../18.ui__ElementNumberProps__filter.ts | 9 + .../18.ui__ElementNumberProps__object.ts | 9 + ...18.ui__ElementRecordPickerProps__filter.ts | 9 + .../18.ui__ElementRecordPickerProps__limit.ts | 9 + ...18.ui__ElementRecordPickerProps__object.ts | 9 + .../18.ui__ElementRecordPickerProps__sort.ts | 9 + .../18.ui__ElementRepeaterProps__filter.ts | 9 + .../18.ui__ElementRepeaterProps__limit.ts | 9 + .../18.ui__ElementRepeaterProps__object.ts | 9 + .../18.ui__ElementRepeaterProps__sort.ts | 9 + .../18.ui__ObjectGridProps__defaultFilters.ts | 10 + .../18.element-flat-data-binding-retired.ts | 59 ++ .../18.element-number-filter-rule-array.ts | 62 -- ...element-record-picker-filter-rule-array.ts | 56 -- .../18.object-grid-default-filters-retired.ts | 39 + ....object-grid-default-filters-rule-array.ts | 81 -- packages/spec/src/migrations/registry.ts | 382 ++++---- packages/spec/src/ui/component.zod.ts | 340 ++++---- 19 files changed, 1340 insertions(+), 595 deletions(-) create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts delete mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index aa4b6181256..b20a36960dc 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -5183,12 +5183,12 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { displayField: 'title' } }, // Both spellings, SAME value: the redundant twin goes (#4923). - { type: 'element:record_picker', properties: { object: 'a', labelField: 'name', displayField: 'name' } }, + { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name', displayField: 'name' } }, // Both spellings, DIFFERENT fields: kept, so the author reconciles // the two rather than the loader picking a column. - { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, // `displayField` is a live LOOKUP-FIELD key elsewhere on the // surface — a different component's business, untouched here. // (The neighbor was `element:form` until #9249 retired that @@ -5202,7 +5202,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { properties: { title: 'Link a project', children: [ - { type: 'element:record_picker', properties: { object: 'd', displayField: 'code' } }, + { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { displayField: 'code' } }, ], }, }, @@ -5217,7 +5217,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { regions: [], slots: { details: [ - { type: 'element:record_picker', properties: { object: 'e', displayField: 'label' } }, + { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { displayField: 'label' } }, ], }, }, @@ -5231,16 +5231,16 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project', labelField: 'title' } }, - { type: 'element:record_picker', properties: { object: 'a', labelField: 'name' } }, - { type: 'element:record_picker', properties: { object: 'b', labelField: 'name', displayField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { labelField: 'title' } }, + { type: 'element:record_picker', dataSource: { object: 'a' }, properties: { labelField: 'name' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { labelField: 'name', displayField: 'title' } }, { type: 'element:text', properties: { displayField: 'title' } }, { type: 'page:card', properties: { title: 'Link a project', children: [ - { type: 'element:record_picker', properties: { object: 'd', labelField: 'code' } }, + { type: 'element:record_picker', dataSource: { object: 'd' }, properties: { labelField: 'code' } }, ], }, }, @@ -5254,7 +5254,7 @@ const recordPickerDisplayFieldToLabelField: MetadataConversion = { regions: [], slots: { details: [ - { type: 'element:record_picker', properties: { object: 'e', labelField: 'label' } }, + { type: 'element:record_picker', dataSource: { object: 'e' }, properties: { labelField: 'label' } }, ], }, }, @@ -5306,7 +5306,8 @@ const recordPickerInertKeysRemoved: MetadataConversion = { components: [ { type: 'element:record_picker', - properties: { object: 'showcase_project', searchFields: ['name', 'code'], multiple: true }, + dataSource: { object: 'showcase_project' }, + properties: { searchFields: ['name', 'code'], multiple: true }, }, // `multiple` is a live FIELD key (lookup fields) — a different // surface entirely, and not this entry's business. (The @@ -5324,7 +5325,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { label: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b', multiple: true } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { multiple: true } }, ], }, ], @@ -5340,7 +5341,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { kind: 'slotted', regions: [], slots: { - details: { type: 'element:record_picker', properties: { object: 'c', searchFields: ['name'] } }, + details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: { searchFields: ['name'] } }, }, }, ], @@ -5353,7 +5354,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { name: 'main', components: [ - { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, { type: 'element:text', properties: { multiple: true } }, { type: 'page:tabs', @@ -5363,7 +5364,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { { label: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, ], }, ], @@ -5378,7 +5379,7 @@ const recordPickerInertKeysRemoved: MetadataConversion = { kind: 'slotted', regions: [], slots: { - details: { type: 'element:record_picker', properties: { object: 'c' } }, + details: { type: 'element:record_picker', dataSource: { object: 'c' }, properties: {} }, }, }, ], @@ -6809,7 +6810,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { type: 'element:text_input', properties: { inputType: 'email', targetVariable: 'contact_email' }, }, - { type: 'element:record_picker', properties: { object: 'showcase_project', targetVariable: 'selected_id' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: { targetVariable: 'selected_id' } }, // Negative control: a `targetVariable` on a type OUTSIDE this // conversion's dispatch proves the strip is scoped by the // component TYPE, not by the key name. It was originally an @@ -6826,7 +6827,7 @@ const elementInputTargetVariableRemoved: MetadataConversion = { properties: { title: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b', targetVariable: 'picked' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: { targetVariable: 'picked' } }, ], }, }, @@ -6863,14 +6864,14 @@ const elementInputTargetVariableRemoved: MetadataConversion = { type: 'element:text_input', properties: { inputType: 'email' }, }, - { type: 'element:record_picker', properties: { object: 'showcase_project' } }, + { type: 'element:record_picker', dataSource: { object: 'showcase_project' }, properties: {} }, { type: 'custom:legacy_input', properties: { targetVariable: 'active_filter' } }, { type: 'page:card', properties: { title: 'Pick one', children: [ - { type: 'element:record_picker', properties: { object: 'b' } }, + { type: 'element:record_picker', dataSource: { object: 'b' }, properties: {} }, ], }, }, @@ -7219,11 +7220,13 @@ const elementFilterRemoved: MetadataConversion = { }, }, // Key overlap on a DIFFERENT type rides through untouched — - // `object` is also a live `element:record_picker` prop, and - // the strip dispatches on the component type, not the key - // name. (The neighbor was `element:form` until #9249 retired - // that element whole; its own strip would now touch the node.) - { type: 'element:record_picker', properties: { object: 'order' } }, + // `object` is also a live `element:metadata_viewer` prop (its + // metadata owner), and the strip dispatches on the component + // type, not the key name. (The neighbor was `element:form` + // until #9249 retired that element whole, then + // `element:record_picker` until #11509 retired its flat + // `object`; either one's own conversion would now touch it.) + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'order_status', object: 'order' } }, // Nested one container down (#6775) — the walk descends. { type: 'page:card', @@ -7263,7 +7266,7 @@ const elementFilterRemoved: MetadataConversion = { name: 'main', components: [ { type: 'element:filter', properties: {} }, - { type: 'element:record_picker', properties: { object: 'order' } }, + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'order_status', object: 'order' } }, { type: 'page:card', properties: { @@ -7290,11 +7293,415 @@ const elementFilterRemoved: MetadataConversion = { ], }, // One per stripped key: 5 on the top-level node, 2 on the nested one, - // 3 on the slotted one. The `element:record_picker` neighbor is untouched. + // 3 on the slotted one. The `element:metadata_viewer` neighbor is untouched. expectedNotices: 10, }, }; +/** + * The element layer's retired flat data-binding keys, per element type — the + * keys {@link elementFlatDataBindingToDataSource} moves onto the node-level + * `dataSource`. Declared here rather than imported from + * `ui/component.zod.ts` (its `RETIRED_ELEMENT_FLAT_BINDING_KEYS`), because this + * module is kept free of the page-component schemas it would drag into every + * bundle of the `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}); + * `element-flat-data-binding-to-data-source.test.ts` holds the two equal. + */ +const ELEMENT_FLAT_BINDING_KEYS_BY_TYPE: Readonly> = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +}; + +/** A rule array, as far as a mechanical append can tell: an array of plain objects. */ +function isRuleObjectArray(value: unknown): value is Dict[] { + return Array.isArray(value) && value.every(isDict); +} + +/** + * What {@link elementFlatDataBindingToDataSource} does with one flat key, by + * the element's OLD resolution of it — the rule each renderer applied before + * objectui#11880 moved all three onto `dataSource` alone: + * + * - `element:record_picker`, and `element:number`'s `object`: the binding (or + * the saved view it names) first, the flat key second + * (`composed?. ?? props.`); + * - `element:number`'s `filter`: AND-combined with the binding's; + * - `element:repeater`: the flat keys ONLY — the binding was not read at all. + */ +type FlatBindingDisposition = + | { kind: 'move' } + | { kind: 'drop' } + | { kind: 'append'; rules: Dict[] } + | { kind: 'todo'; reason: string }; + +function flatBindingDisposition( + type: string, + key: string, + value: unknown, + binding: Dict | undefined, +): FlatBindingDisposition { + const bound = binding !== undefined && binding[key] !== undefined; + const view = binding && typeof binding.view === 'string' && binding.view.length > 0 ? binding.view : undefined; + + if (type === 'element:repeater') { + if (bound) { + if (deepEqualAuthored(binding![key], value)) return { kind: 'drop' }; + return { + kind: 'todo', + reason: `\`dataSource.${key}\` is set to a different value than this flat \`${key}\`. The list read ` + + 'only its flat keys until it moved onto the binding, so the binding\'s value never applied; now it ' + + `is the only one read. Keep the value you mean in \`dataSource.${key}\` and delete this key.`, + }; + } + if (view !== undefined && key !== 'object') { + return { + kind: 'todo', + reason: `the binding names the saved view \`${view}\`, which the list did not read until it moved onto ` + + `the binding. Moved there, this \`${key}\` would combine with the view's own (a filter ANDs, a ` + + 'sort or a limit overrides it), which the list never did. Decide whether the list should apply the ' + + `view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, + }; + } + return { kind: 'move' }; + } + + if (type === 'element:number' && key === 'filter') { + if (!bound) return { kind: 'move' }; + if (isRuleObjectArray(binding!.filter) && isRuleObjectArray(value)) { + return { kind: 'append', rules: [...binding!.filter, ...value] }; + } + return { + kind: 'todo', + reason: 'this flat `filter` and `dataSource.filter` both carry rules, and the element AND-combined ' + + 'them; one of the two is not a rule array, so they cannot be appended mechanically. Write the rules ' + + 'of both into `dataSource.filter` (they AND) and delete this key.', + }; + } + + // `element:record_picker`, and `element:number`'s `object`. + if (bound) return { kind: 'drop' }; + if (view !== undefined && key !== 'object') { + return { + kind: 'todo', + reason: `this flat \`${key}\` sits beside \`dataSource.view: '${view}'\`, and the binding sets no \`${key}\` ` + + 'of its own. It was read only when that view supplied none, so whether it ever applied depends on ' + + `the view, which no conversion reads. If the view sets no \`${key}\`, move this one to ` + + `\`dataSource.${key}\`; if it does, delete it.`, + }; + } + return { kind: 'move' }; +} + +/** + * The element layer's flat data-binding keys move onto the node-level + * `dataSource` binding (protocol 18, #11509 — ruling A-narrow: in v18 an + * element binds data through `dataSource` only, so one node carries one door + * with one precedence). + * + * Ten keys on three elements: `element:record_picker` `object` / `filter` / + * `sort` / `limit`, `element:number` `object` / `filter`, and + * `element:repeater` `object` / `filter` / `sort` / `limit`. Each was the + * same query as a key of `ElementDataSourceSchema`, resolved per renderer by + * three different rules, and objectui#11880 (objectui `5bc55c0c5a1e`) moved + * all three renderers onto the binding alone — the order the ruling set, so + * this rewrite never moves a working list's query into a position its + * renderer does not read. + * + * Mechanical where the OLD rule decides the answer + * ({@link flatBindingDisposition}): + * + * - the binding lacks the key → the value MOVES there, unchanged; + * - the record picker (and `element:number`'s `object`) where the binding + * already sets the key → the flat key is DELETED: the binding always won, + * so the flat value never applied; + * - `element:number`'s `filter` beside the binding's → APPENDED to it: the + * renderer AND-combined the two, and a rule array is an AND; + * - the repeater where the binding already holds the SAME value → deleted. + * + * Left as stored, and reported as a TODO, where it does not: a record-picker + * key the binding lacks beside a `dataSource.view` (whether the view's own key + * displaced it depends on the view, which no conversion reads); a repeater key + * the binding sets to a different value, or beside a `view` (the repeater read + * neither before, so moving it would combine it with what the list never + * applied); and an `element:number` filter pair that is not two rule arrays. A + * key left as stored no longer reaches a query, and its tombstone refuses it + * at the next parse with the same prescription. + * + * Runs BEFORE `page-component-filter-record-to-rule-array` (order 35.5, below + * its 36): a flat `filter` in the retired record form moves onto + * `dataSource.filter`, where that entry converts it like any other binding + * filter — so neither entry carries an element-layer `properties.filter` arm. + * + * Retired from the load path: an author writing a flat key is refused at the + * parse with the prescription; stored rows, artifacts and + * `os migrate meta --from 17` replay it. Idempotent by construction — a node + * with no flat key left is returned by reference. + */ +const elementFlatDataBindingToDataSource: MetadataConversion = { + id: 'element-flat-data-binding-to-data-source', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: + 'page.component.element:record_picker.object / page.component.element:record_picker.filter / ' + + 'page.component.element:record_picker.sort / page.component.element:record_picker.limit / ' + + 'page.component.element:number.object / page.component.element:number.filter / ' + + 'page.component.element:repeater.object / page.component.element:repeater.filter / ' + + 'page.component.element:repeater.sort / page.component.element:repeater.limit', + summary: + "the element layer's flat data-binding keys removed — 'object' / 'filter' / 'sort' / 'limit' on " + + "element:record_picker and element:repeater, 'object' / 'filter' on element:number — each the " + + "same query as a key of the node-level 'dataSource' binding, the one door the element reads: a " + + 'key the binding lacks moves there unchanged, one the binding already set is deleted where the ' + + "binding always won (and element:number's filter is appended to the binding's, since the two " + + "always AND-combined); a key whose effect depended on a saved 'dataSource.view', or that " + + "disagrees with a binding the repeater never read, is left as stored and reported as a TODO", + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string' || !Object.prototype.hasOwnProperty.call(ELEMENT_FLAT_BINDING_KEYS_BY_TYPE, type)) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const keys = ELEMENT_FLAT_BINDING_KEYS_BY_TYPE[type]!.filter((key) => key in properties); + if (keys.length === 0) return component; + + const original = isDict(component.dataSource) ? component.dataSource : undefined; + let binding = original; + let props: Dict = properties; + const block = describeBlock(component); + + for (const key of keys) { + const value = props[key]; + const disposition = value === undefined + ? ({ kind: 'drop' } as const) + : flatBindingDisposition(type, key, value, binding); + switch (disposition.kind) { + case 'todo': + context?.reportTodo?.({ + path: `${path}.properties.${key}`, + from: JSON.stringify(value), + reason: `On ${block}, ${disposition.reason} Left as stored, this key reaches no query.`, + }); + continue; + case 'drop': + props = stripKeys(props, [key], emit, `${path}.properties`); + continue; + case 'append': { + const { [key]: _appended, ...rest } = props; + props = rest; + binding = { ...binding, filter: disposition.rules }; + emit({ from: `properties.${key}`, to: `dataSource.${key} (rules appended; they AND)`, path: `${path}.dataSource.${key}` }); + continue; + } + case 'move': { + const { [key]: _moved, ...rest } = props; + props = rest; + binding = { ...binding, [key]: value }; + emit({ from: `properties.${key}`, to: `dataSource.${key}`, path: `${path}.dataSource.${key}` }); + continue; + } + } + } + + if (props === properties) return component; + return binding === original + ? { ...component, properties: props } + : { ...component, dataSource: binding, properties: props }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'deal_desk', + regions: [ + { + name: 'main', + components: [ + // No binding: all four flat keys move onto a new one. + { + type: 'element:record_picker', + id: 'p1', + properties: { + object: 'deal', + labelField: 'name', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, + }, + }, + // The binding already names the object: the flat one never + // applied (the binding won) and is deleted; the binding lacks a + // limit, so the flat one moves. + { + type: 'element:record_picker', + id: 'p2', + dataSource: { object: 'deal' }, + properties: { object: 'lead', limit: 10 }, + }, + // Beside a saved view the binding sets no filter of its own: + // whether the flat filter ever applied depends on the view, so + // it is left as stored — a TODO, no notice. + { + type: 'element:record_picker', + id: 'p3', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }] }, + }, + { + type: 'page:card', + properties: { + children: [ + // Nested, no binding: `object` and `filter` move. + { + type: 'element:number', + id: 'n1', + properties: { + object: 'deal', + aggregate: 'count', + filter: [{ field: 'stage', operator: 'equals', value: 'won' }], + }, + }, + // Both filters set: the element AND-combined them, so the + // flat rules are appended to the binding's. + { + type: 'element:number', + id: 'n2', + dataSource: { object: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'won' }] }, + properties: { + aggregate: 'sum', + field: 'amount', + filter: [{ field: 'amount', operator: 'greater_than', value: 0 }], + }, + }, + ], + }, + }, + // `object` on an element outside the family (the metadata + // owner of a viewer) is not this entry's key. + { + type: 'element:metadata_viewer', + properties: { type: 'state_machine', name: 'deal_stage', object: 'deal' }, + }, + ], + }, + ], + }, + // The named-slot shape: a repeater, which read its flat keys alone. + { + name: 'deal_detail', + kind: 'slotted', + regions: [], + slots: { + side: { + type: 'element:repeater', + id: 'r1', + properties: { + object: 'deal_note', + titleField: 'subject', + filter: [{ field: 'pinned', operator: 'equals', value: true }], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 5, + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'deal_desk', + regions: [ + { + name: 'main', + components: [ + { + type: 'element:record_picker', + id: 'p1', + properties: { labelField: 'name' }, + dataSource: { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, + }, + }, + { + type: 'element:record_picker', + id: 'p2', + dataSource: { object: 'deal', limit: 10 }, + properties: {}, + }, + { + type: 'element:record_picker', + id: 'p3', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }] }, + }, + { + type: 'page:card', + properties: { + children: [ + { + type: 'element:number', + id: 'n1', + properties: { aggregate: 'count' }, + dataSource: { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'won' }], + }, + }, + { + type: 'element:number', + id: 'n2', + dataSource: { + object: 'deal', + filter: [ + { field: 'stage', operator: 'equals', value: 'won' }, + { field: 'amount', operator: 'greater_than', value: 0 }, + ], + }, + properties: { aggregate: 'sum', field: 'amount' }, + }, + ], + }, + }, + { + type: 'element:metadata_viewer', + properties: { type: 'state_machine', name: 'deal_stage', object: 'deal' }, + }, + ], + }, + ], + }, + { + name: 'deal_detail', + kind: 'slotted', + regions: [], + slots: { + side: { + type: 'element:repeater', + id: 'r1', + properties: { titleField: 'subject' }, + dataSource: { + object: 'deal_note', + filter: [{ field: 'pinned', operator: 'equals', value: true }], + sort: [{ field: 'created_at', order: 'desc' }], + limit: 5, + }, + }, + }, + }, + ], + }, + // One per flat key moved, deleted or appended: 4 on `p1`, 2 on `p2`, 0 on + // `p3` (its TODO), 2 on `n1`, 1 on `n2`, 4 on `r1`. + expectedNotices: 13, + }, +}; + /** * `element:form` — the whole element retired (protocol 18, #9249, ADR-0049 * enforce-or-remove at ELEMENT grain). @@ -7373,9 +7780,12 @@ const elementFormRemoved: MetadataConversion = { }, }, // Key overlap on a DIFFERENT type rides through untouched — - // `object` is also a live `element:record_picker` prop, and - // the strip dispatches on the component type, not the key name. - { type: 'element:record_picker', properties: { object: 'lead' } }, + // `object` is also a live `element:metadata_viewer` prop (its + // metadata owner), and the strip dispatches on the component + // type, not the key name. (The neighbor was + // `element:record_picker` until #11509 retired its flat + // `object`; that retirement's conversion would now touch it.) + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'lead_status', object: 'lead' } }, // Nested one container down (#6775) — the walk descends. { type: 'page:card', @@ -7415,7 +7825,7 @@ const elementFormRemoved: MetadataConversion = { name: 'main', components: [ { type: 'element:form', properties: {} }, - { type: 'element:record_picker', properties: { object: 'lead' } }, + { type: 'element:metadata_viewer', properties: { type: 'state_machine', name: 'lead_status', object: 'lead' } }, { type: 'page:card', properties: { @@ -7442,7 +7852,7 @@ const elementFormRemoved: MetadataConversion = { ], }, // One per stripped key: 5 on the top-level node, 2 on the nested one, - // 3 on the slotted one. The `element:record_picker` neighbor is untouched. + // 3 on the slotted one. The `element:metadata_viewer` neighbor is untouched. expectedNotices: 10, }, }; @@ -9233,6 +9643,267 @@ const pageComponentResponsiveRemoved: MetadataConversion = { }, }; +/** + * A `filter` value the grid's lowering reads as NOTHING — absent, `null`, an + * empty rule array or an empty record — which is exactly when `object-grid` + * read `defaultFilters` instead. + */ +function gridFilterIsEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (Array.isArray(value)) return value.length === 0; + return isDict(value) && Object.keys(value).length === 0; +} + +/** A `filter` value with rules (or record keys) in it — the grid read it, and never `defaultFilters`. */ +function gridFilterHasContent(value: unknown): boolean { + if (Array.isArray(value)) return value.length > 0; + return isDict(value) && Object.keys(value).length > 0; +} + +/** + * `object-grid`'s legacy base-filter fallback leaves the contract (protocol 18, + * #11509, ruling A-narrow, sub-question 1: "retires with a conversion in the + * shape `defaultSort`'s took"). + * + * `defaultFilters` was the second spelling of `filter`: the same rules, read by + * the grid only when `filter` lowered to nothing. #19514 narrowed it to the + * rule array and said, in as many words, that refusing it outright needed its + * own ruling; #11509 is that ruling. Its narrowing's D3 entry + * (`object-grid-default-filters-rule-array`, unreleased in any major) is + * absorbed into this retirement's, and so is the narrowing's D2 arm: the + * `properties.defaultFilters` reach of `page-component-filter-record-to-rule-array` + * is gone, because this entry runs BEFORE that one (order 35.5, below its 36) + * and leaves no `defaultFilters` for it to see — a record-form fallback it + * moves lands on `filter`, where that entry converts it like any other. + * + * The renderer's own precedence decides the rewrite, as it did for + * {@link objectGridDefaultSortRemoved}: + * + * - `filter` EMPTY (absent, `null`, `[]` or `{}`) — the fallback WAS the + * grid's filter, so it moves onto `filter`, unchanged; + * - `filter` WITH CONTENT — the fallback was never read, so it is deleted (a + * pure lossless delete), and so is an empty fallback beside anything; + * - `filter` a value no lowering reads (a bare string, a number, a boolean) — + * the grid fell back to `defaultFilters` there too, but moving the fallback + * would overwrite what the author wrote at `filter`, so the site is left as + * stored and reported as a TODO. + * + * Zero authored occurrences in this repository (the showcase pins it at zero), + * so the entry exists for stored `sys_metadata` rows and for authors outside + * the repo. Retired from the load path: an author writing the key is refused + * at the parse with the prescription. + */ +const objectGridDefaultFiltersRemoved: MetadataConversion = { + id: 'object-grid-default-filters-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.component.object-grid.defaultFilters', + summary: + "object-grid component prop 'defaultFilters' removed (the legacy second spelling of 'filter', read " + + "only when 'filter' lowered to nothing; its rules move onto an empty 'filter', and the key is " + + "deleted beside a 'filter' that has content, which the grid always read instead)", + apply(stack, emit, context) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-grid') return component; + const properties = component.properties; + if (!isDict(properties) || !('defaultFilters' in properties)) return component; + const fallback = properties.defaultFilters; + const filter = properties.filter; + if (gridFilterIsEmpty(fallback) || gridFilterHasContent(filter)) { + // Nothing to carry, or `filter` won: a lossless delete. + return { ...component, properties: stripKeys(properties, ['defaultFilters'], emit, `${path}.properties`) }; + } + if (gridFilterIsEmpty(filter)) { + // The fallback WAS the filter: it moves, unchanged. + const { defaultFilters, ...rest } = properties; + emit({ from: 'defaultFilters', to: 'filter', path: `${path}.properties.filter` }); + return { ...component, properties: { ...rest, filter: defaultFilters } }; + } + context?.reportTodo?.({ + path: `${path}.properties.defaultFilters`, + from: JSON.stringify(fallback), + reason: `On ${describeBlock(component)}, \`filter\` is ${JSON.stringify(filter)}, a value no filter ` + + 'lowering reads, so the grid fell back to `defaultFilters`; moving the fallback onto `filter` ' + + 'would overwrite what was written there. Write the rules the grid should apply at `filter` and ' + + 'delete `defaultFilters`. Left as stored, it no longer reaches the query.', + }); + return component; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + // No `filter`: the fallback WAS the filter, so it moves. + { + type: 'object-grid', + id: 'g1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // `filter` has rules: the fallback was never read — deleted. + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // `filter: []` lowers to nothing: the grid read the fallback. + { + type: 'object-grid', + id: 'g3', + properties: { + objectName: 'crm_task', + filter: [], + defaultFilters: [{ field: 'priority', operator: 'equals', value: 'high' }], + }, + }, + // `defaultFilters` on a component that is not an object-grid — + // not this entry's key (scoped by component type, never by key + // name). + { + type: 'object-kanban', + id: 'k1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + // Nested one container down: still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { + objectName: 'crm_lead', + defaultFilters: [{ field: 'status', operator: 'equals', value: 'new' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape: `filter: {}` (an empty record) lowers to + // nothing too, so the fallback moves onto it. + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { + objectName: 'crm_task', + filter: {}, + defaultFilters: [{ field: 'status', operator: 'not_equals', value: 'done' }], + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'work_queue', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-grid', + id: 'g1', + properties: { + objectName: 'crm_task', + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + { + type: 'object-grid', + id: 'g2', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'equals', value: 'open' }], + }, + }, + { + type: 'object-grid', + id: 'g3', + properties: { + objectName: 'crm_task', + filter: [{ field: 'priority', operator: 'equals', value: 'high' }], + }, + }, + { + type: 'object-kanban', + id: 'k1', + properties: { + objectName: 'crm_task', + defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], + }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-grid', + id: 'g4', + properties: { + objectName: 'crm_lead', + filter: [{ field: 'status', operator: 'equals', value: 'new' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'work_queue_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-grid', + id: 'g5', + properties: { + objectName: 'crm_task', + filter: [{ field: 'status', operator: 'not_equals', value: 'done' }], + }, + }, + }, + }, + ], + }, + // One per grid that carried the key: g1, g2, g3, g4, g5. The kanban + // neighbour is untouched. + expectedNotices: 5, + }, +}; + /** * `object-grid`'s legacy single-sort fallback leaves the contract (protocol 18, * #11805, ADR-0049 enforce-or-remove; maintainer ruling 2026-08-25, @@ -13424,16 +14095,13 @@ const RULE_ARRAY_FILTER_BLOCK_TYPES: ReadonlySet = new Set([ 'object-gantt', 'object-tree', 'object-timeline', - 'element:number', - 'element:record_picker', ]); - -/** - * The one component type whose `properties.defaultFilters` is a rule-array - * door of the same family (`object-grid-default-filters-rule-array`). Held - * against the schema by the same test. - */ -const RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES: ReadonlySet = new Set(['object-grid']); +// `element:number` and `element:record_picker` left this list, and the +// `object-grid` `properties.defaultFilters` arm left this entry, with #11509: +// those three flat keys retired in v18, and the two entries that remove them +// (`element-flat-data-binding-to-data-source`, `object-grid-default-filters-removed`) +// run before this one, so a record form they carry reaches this entry at the +// door it moved to — `dataSource.filter`, or the grid's own `filter`. /** One rule a legacy filter maps to — the rule array's authored element. */ interface MappedFilterRule { @@ -13763,7 +14431,7 @@ function describeBlock(component: Dict): string { * refusal lands differs by door, measured through `saveMetaItem` * (`protocol.stored-migration.test.ts`): `dataSource.filter` is a declared key * of the strict page-component schema, so the row's next save is refused - * there; `properties.filter` / `properties.defaultFilters` sit in the open + * there; `properties.filter` sits in the open * `properties` bag the runtime save does not refuse by component type, so there * the refusal is the component-props gate's (`@objectstack/lint`, advisory), * and a re-save goes through. @@ -13784,10 +14452,14 @@ function describeBlock(component: Dict): string { * * Every page component `mapPageComponents` visits (regions, slots, nested * containers): `dataSource.filter` on any component (`ElementDataSourceSchema`), - * `properties.filter` on {@link RULE_ARRAY_FILTER_BLOCK_TYPES}, and - * `properties.defaultFilters` on {@link RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES}. - * The `filter` of any other component type is not this entry's surface and is - * never touched. + * and `properties.filter` on {@link RULE_ARRAY_FILTER_BLOCK_TYPES}. The `filter` + * of any other component type is not this entry's surface and is never + * touched. Until #11509 the reach also took the `properties.filter` of + * `element:number` and `element:record_picker` and the `properties.defaultFilters` + * of `object-grid`; those keys retired in v18, and the entries that move them + * (`element-flat-data-binding-to-data-source`, `object-grid-default-filters-removed`) + * run first, so their values arrive here at `dataSource.filter` or at the + * grid's `filter`. * * Where a component's rows come from does not move the verdict. A block whose * rows ride on the node (`data: { provider: 'value' }`, a `data` array, @@ -13818,9 +14490,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { retiredFromLoadPath: true, retiredAfter: '17.4.0', surface: - 'page.component.dataSource.filter / page.component.properties.filter (the object-* blocks, ' - + 'element:number, element:record_picker) / page.component.properties.defaultFilters ' - + '(object-grid) — the record and single-level AST filter forms', + 'page.component.dataSource.filter / page.component.properties.filter (the object-* blocks) ' + + '— the record and single-level AST filter forms', summary: 'a record-form or single-level AST filter at a converged rule-array door becomes the ' + '`[{ field, operator, value }]` rule array wherever the mapping is lossless (flat keys → ' @@ -13871,9 +14542,6 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { if (RULE_ARRAY_FILTER_BLOCK_TYPES.has(type)) { props = rewrite(props, 'filter', `${path}.properties`); } - if (RULE_ARRAY_DEFAULT_FILTERS_BLOCK_TYPES.has(type)) { - props = rewrite(props, 'defaultFilters', `${path}.properties`); - } if (props !== properties) next = { ...next, properties: props }; } @@ -13890,8 +14558,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { { name: 'main', components: [ - // The binding and both grid doors at once: a flat record with - // two keys, an operator object, and an AST tuple array. + // The binding and the grid door at once: a flat record with + // two keys, and an operator object. { type: 'object-grid', dataSource: { @@ -13901,7 +14569,16 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { properties: { objectName: 'deal', filter: { amount: { $gt: 100, $lte: 5000 } }, - defaultFilters: [['owner_id', '=', '{current_user_id}']], + }, + }, + // An AST tuple array, at another block door. (It sat on the + // grid's `defaultFilters` until #11509 retired that key in v18 + // and took its door out of this entry's reach.) + { + type: 'object-calendar', + properties: { + objectName: 'deal', + filter: [['owner_id', '=', '{current_user_id}']], }, }, // A combinator is never flattened: left byte-identical. @@ -13931,18 +14608,20 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { }, }, // Nested inside a container: reached, and a legacy shorthand - // operator lands on its canonical spelling. + // operator lands on its canonical spelling — at the element's + // binding, the one door an element carries since #11509 + // retired its flat `filter`. { type: 'page:card', properties: { children: [ { type: 'element:number', - properties: { + dataSource: { object: 'deal', - aggregate: 'count', filter: { stage: { $nin: ['lost', 'void'] } }, }, + properties: { aggregate: 'count' }, }, ], }, @@ -13977,9 +14656,13 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { { field: 'amount', operator: 'greater_than', value: 100 }, { field: 'amount', operator: 'less_than_or_equal', value: 5000 }, ], - defaultFilters: [ - { field: 'owner_id', operator: 'equals', value: '{current_user_id}' }, - ], + }, + }, + { + type: 'object-calendar', + properties: { + objectName: 'deal', + filter: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }], }, }, { @@ -14010,11 +14693,11 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { children: [ { type: 'element:number', - properties: { + dataSource: { object: 'deal', - aggregate: 'count', filter: [{ field: 'stage', operator: 'not_in', value: ['lost', 'void'] }], }, + properties: { aggregate: 'count' }, }, ], }, @@ -14025,8 +14708,9 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { }, ], }, - // One per converted door: the binding, the grid filter, the grid - // defaultFilters, the inline-row map's filter, the nested element:number. + // One per converted door: the grid's binding, the grid filter, the + // calendar's AST filter, the inline-row map's filter, the nested + // element:number's binding. expectedNotices: 5, }, }; @@ -15043,6 +15727,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: datasetCountMeasureEmptyFieldRemoved, order: 54 }, { conversion: declaredIndexUniqueScope, order: 61 }, { conversion: elementFilterRemoved, order: 4 }, + { conversion: elementFlatDataBindingToDataSource, order: 35.5 }, { conversion: elementFormRemoved, order: 5 }, { conversion: elementInputTargetVariableRemoved, order: 3 }, { conversion: elementTextVariantHeadingLevels, order: 59 }, @@ -15061,6 +15746,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: mappingLookupParamsRemoved, order: 11 }, { conversion: memoryPersistenceAutoSaveIntervalToMs, order: 27 }, { conversion: metricFiltersRemoved, order: 7 }, + { conversion: objectGridDefaultFiltersRemoved, order: 35.5 }, { conversion: objectGridDefaultSortRemoved, order: 14 }, { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts new file mode 100644 index 00000000000..6afc331ef76 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:number` was a flat second +// spelling of the node-level `dataSource.filter` binding (the element resolved `object` binding-first and AND-combined the two filters). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementNumberProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts new file mode 100644 index 00000000000..126e11e6d77 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementNumberProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:number` was a flat second +// spelling of the node-level `dataSource.object` binding (the element resolved `object` binding-first and AND-combined the two filters). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementNumberProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts new file mode 100644 index 00000000000..4f6bddde60d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.filter` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts new file mode 100644 index 00000000000..9afca13b318 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__limit.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `limit` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.limit` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:limit'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts new file mode 100644 index 00000000000..8659ff3ea0c --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.object` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts new file mode 100644 index 00000000000..fc5b5ed73c0 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRecordPickerProps__sort.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `sort` on `element:record_picker` was a flat second +// spelling of the node-level `dataSource.sort` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRecordPickerProps:sort'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts new file mode 100644 index 00000000000..34c13911e5f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__filter.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `filter` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.filter` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:filter'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts new file mode 100644 index 00000000000..fc2d07d545a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__limit.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `limit` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.limit` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:limit'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts new file mode 100644 index 00000000000..76ea8f58ad5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__object.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `object` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.object` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts new file mode 100644 index 00000000000..5005a65425f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementRepeaterProps__sort.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow) — `sort` on `element:repeater` was a flat second +// spelling of the node-level `dataSource.sort` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). +// In v18 an element binds data through `dataSource` only: one node, one +// door, one precedence. Sources are rewritten by the D2 conversion +// `element-flat-data-binding-to-data-source`; the judgment an upgrader +// still owes is the D3 entry `element-flat-data-binding-retired`. +export const entry = 'ui/ElementRepeaterProps:sort'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts new file mode 100644 index 00000000000..89033d246bb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectGridProps__defaultFilters.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #11509 (v18, ruling A-narrow, sub-question 1) — `defaultFilters` on +// `object-grid` was the legacy second spelling of `filter`, read only when +// `filter` lowered to nothing; #19514 narrowed it to the rule array and left +// the removal to its own ruling, which this is. Retired in the shape +// `defaultSort`'s took (`ui/ObjectGridProps:defaultSort`, beside this entry): +// the D2 conversion `object-grid-default-filters-removed` moves the rules onto +// an empty `filter` and deletes the key beside a `filter` that has content. +export const entry = 'ui/ObjectGridProps:defaultFilters'; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts new file mode 100644 index 00000000000..134f7ead282 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11509 (v18, ruling A-narrow) — the D3 entry of the +// `element-flat-data-binding-to-data-source` family: one entry for the ten +// keys, because they are one retirement (the element layer's second data door) +// and an upgrader moves them together. It absorbs the two protocol-18 +// narrowings of the element flat `filter` to the rule array +// (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`): +// the key they narrowed is gone in the same major, and the rule array they +// prescribed is the binding's own form. The conversion follows each element's +// old rule, so what it cannot follow is listed as a TODO and judged here. +export const entry: SemanticMigration = { + id: 'element-flat-data-binding-retired', + surface: + 'page.component properties of element:record_picker (object, filter, sort, limit), ' + + 'element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat ' + + 'data-binding keys beside the node-level dataSource', + replacement: + '`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of ' + + '`type` rather than a key inside `properties` — the one binding each of the three elements reads. ' + + 'Each key moves unchanged: `properties: { object: \'deal\', limit: 20 }` becomes ' + + '`dataSource: { object: \'deal\', limit: 20 }`, and a filter keeps its rule-array form ' + + '`[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a ' + + '`view`, the view supplies the baseline, an explicit binding key overrides it, and the binding ' + + 'filter AND-combines with the view\'s.', + reason: + 'One node carried two doors onto one query, resolved by three different rules: the record picker ' + + 'let the binding win (its flat key was read only when the binding, or the saved view the binding ' + + 'named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two ' + + 'filters, and the repeater read its flat keys alone and ignored the binding — while the ' + + 'component-props gate waived a missing flat `object` whenever `dataSource.object` was present, ' + + 'so a repeater bound only through `dataSource` passed validation and drew an empty list. The ' + + 'console moved all three elements onto the binding first, and in v18 the flat keys are ' + + 'refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element\'s ' + + 'old rule: a key the binding lacks moves there, a key the binding already set is deleted where ' + + 'the binding won, and `element:number`\'s filter is appended to the binding\'s. Three cases are ' + + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' + + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' + + 'wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a ' + + 'host, a generator, a designer — must write the binding, which no conversion reaches. And the ' + + 'gate now requires `dataSource.object` on all three elements: a node with none names no object ' + + 'and is reported.', + acceptanceCriteria: + 'No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, ' + + '`filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has ' + + '`dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running ' + + 'page each picker offers, each number aggregates and each repeater lists the records the author ' + + 'intends — checked first on every node the migration listed as a TODO, and on every repeater ' + + 'that already carried a `dataSource`.', + conversionIds: ['element-flat-data-binding-to-data-source'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts deleted file mode 100644 index cfa60fc34fd..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.element-number-filter-rule-array.ts +++ /dev/null @@ -1,62 +0,0 @@ -// 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 (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' - + "exception). `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): ' - + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' - + '`translateFilterArray` ' - + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' - + 'of the array corrects it here. ' - + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' - + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' - + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' - + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' - + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' - + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' - + 'else — so an un-lowered array is refused there before any service code runs. ' - + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' - + 'analytics path) for callers reaching `analyticsService.query` ' - + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'The adapter-side lowering lands in the console\'s own repository. ' - + '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 ' - + 'the console-side half of this convergence.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts deleted file mode 100644 index 6dc4604beb8..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.element-record-picker-filter-rule-array.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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()`, a gap measured on its own). 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 (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' - + "exceptions). `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: 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()`, 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: measure the consumer\'s read path before the contract moves): ' - + '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 — ' - + 'a console-side change filed in the objectui repository, blocked on that release.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts new file mode 100644 index 00000000000..8eacd237f0b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #11509 (v18, ruling A-narrow, sub-question 1) — the D3 entry of the +// `object-grid-default-filters-removed` family, in the shape the +// `object-grid-default-sort-retired` entry beside it took. It absorbs the +// protocol-18 narrowing of the same key (`object-grid-default-filters-rule-array`, +// never released in a major): #19514 narrowed `defaultFilters` to the rule array +// and left its removal to a ruling of its own, which this is. +export const entry: SemanticMigration = { + id: 'object-grid-default-filters-retired', + surface: + 'page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter', + replacement: + '`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; ' + + 'the same rules, unchanged.', + reason: + 'The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two ' + + 'spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that ' + + 'precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid\'s ' + + 'filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, ' + + 'so it is deleted. Both preserve what the grid showed, and the second is where the judgment ' + + 'sits: a grid that authored both keys has always listed the rows `filter` selects, while its ' + + 'author may believe `defaultFilters` applied. The conversion keeps the rows users have been ' + + 'seeing and discards the rules that were written; only the author can say which were meant. ' + + 'A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and ' + + 'listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would ' + + 'overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` ' + + 'and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping ' + + 'is lossless; that entry lists the rest. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: + 'No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid\'s `filter` ' + + 'holds the rules the author intends, and the grid lists exactly the rows they select. For every ' + + 'grid that had authored both keys, the author has compared the discarded `defaultFilters` rules ' + + 'with the kept `filter` and confirmed the kept one.', + conversionIds: ['object-grid-default-filters-removed'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts deleted file mode 100644 index a4fa9b563fe..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// The key the one-filter-orthography convergence did not name. Its sibling -// entry element-data-source-and-object-block-filter-rule-array says so in as -// many words — 「object-grid.defaultFilters is a different key and is not named -// by the ruling this entry records」 — so this is the entry that names it. -export const entry: SemanticMigration = { - id: 'object-grid-default-filters-rule-array', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. - surface: - 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' - + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' - + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' - + 'rules alike', - replacement: - 'the same ViewFilterRule array form its sibling filter takes — ' - + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' - + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' - + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' - + 'operator into the rule, becoming ' - + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' - + '[["owner_id", "=", "{current_user_id}"]] becomes ' - + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' - + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' - + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' - + 'is read only when filter is absent, and its own description has prescribed filter all ' - + 'along', - reason: - 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' - + 'render-time filter converter — the protocol is the only refusal set, so a document it ' - + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' - + 'protocol\'s to close」. This is the SAME value ' - + 'in the SAME role as filter — the key\'s own description says it is read only when ' - + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' - + 'refusal that sink can give was reachable from a document the protocol had just ' - + 'accepted. filter converged on the rule array with the rest of its family; this key was ' - + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' - + 'said nothing about what the grid would then do with the value, and in the objectui ' - + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' - + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' - + 'tuple array were lowered and applied as declared; a bare string or a number was ' - + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' - + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' - + 'client before any request for the value shapes it judges itself. ' - + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' - + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' - + 'own ruling; the deprecation already stated in the description is unchanged and still ' - + 'says to prefer filter. ' - + 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to ' - + 'the rule array by the same D2 conversion as its sibling filter, ' - + 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by ' - + 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot ' - + 'map losslessly is left exactly as stored and keeps rendering as it does today — a ' - + 'combinator, a null value, an operator the rule vocabulary does not spell, or the bare ' - + 'string or number this key also took — and ' - + 'its door refuses such a value only as the component-props gate\'s advisory finding ' - + '(os validate, os build, os lint), since a re-save through the metadata API is not ' - + 'refused there: a record form with the message the filter door gives, a worked rewrite ' - + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' - + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' - + 'refusal. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'Every object-grid node in your pages either omits defaultFilters or carries a ' - + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' - + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' - + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. What to re-check ' - + 'depends on the shape that was there, as the objectui version this release pins treats ' - + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' - + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' - + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' - + 'show before writing the rule that selects them. A list of malformed rules was refused ' - + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' - + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' - + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' - + 'rules onto filter rather than deleting them.', -}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1ea24b09811..31102789a37 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5543,6 +5543,24 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' + 'toolbar\'s filter builder.', }, + { + id: 'element-flat-data-binding-retired', + order: 90, + text: + 'It also retires the element layer\'s second data door (#11509, ruling A-narrow): the flat ' + + '`object` / `filter` / `sort` / `limit` keys of `element:record_picker` and ' + + '`element:repeater` and the flat `object` / `filter` of `element:number`, each the same query ' + + 'as a key of the node-level `dataSource` binding, resolved per element by three contradictory ' + + 'rules. The console moved all three elements onto the binding first, so from this major an ' + + 'element binds data through `dataSource` only; the keys are retiredKey tombstones, and the ' + + 'component-props gate requires `dataSource.object` on the three instead of waiving the flat ' + + 'key for them — which also closes the repeater that passed validation bound only through a ' + + 'binding it did not read. The D2 conversion `element-flat-data-binding-to-data-source` follows ' + + 'each element\'s old rule (move where the binding lacks the key, delete where the binding won, ' + + 'append `element:number`\'s filter, which AND-combined) and runs before the record-form filter ' + + 'conversion, which then converts a moved record form at `dataSource.filter`; what the old rule ' + + 'leaves undecided is a TODO, judged by the D3 entry.', + }, { id: 'element-form-retired', order: 8, @@ -5989,6 +6007,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'authoring it configured nothing. A kind enters the live set as a side effect of ' + 'registering an item of that kind.', }, + { + id: 'object-grid-default-filters-retired', + order: 90, + text: + 'It also retires `object-grid`\'s `defaultFilters` (#11509, ruling A-narrow, in the shape ' + + 'of the `defaultSort` retirement): the legacy second spelling of `filter`, read only when ' + + '`filter` lowered to nothing, which an earlier narrowing in this major had shaped as the rule ' + + 'array and this retirement absorbs. The mechanical conversion moves the rules onto an empty ' + + '`filter` and deletes the key beside a `filter` with content (the renderer never read it ' + + 'there); it runs before the record-form filter conversion, which then converts a moved record ' + + 'form at `filter`.', + }, { id: 'object-grid-default-sort-retired', order: 17, @@ -6104,9 +6134,8 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'spelling platform-wide, the rule array) its ' + 'mechanical half at rest (ruled 2026-09-12): the D2 conversion ' + '`page-component-filter-record-to-rule-array` rewrites a record-form or single-level ' - + 'AST `filter` at the converged rule-array doors — `dataSource.filter`, the ' - + '`object-*` / `element:number` / `element:record_picker` `filter` props and ' - + '`object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, ' + + 'AST `filter` at the converged rule-array doors — `dataSource.filter` and the ' + + '`object-*` `filter` props — to the rule array wherever the mapping is lossless, ' + 'and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless ' + 'rule spelling) exactly as stored, because flattening a combinator changes which rows ' + 'a page selects. It is retired from the load path, so authors are still refused at ' @@ -11470,6 +11499,61 @@ const step18: MigrationStep = { + '`schemaValid: true` in `--json`, and the run closes with the schema-valid line ' + 'rather than the manual-changes warning', }, + // #11509 (v18, ruling A-narrow) — the D3 entry of the + // `element-flat-data-binding-to-data-source` family: one entry for the ten + // keys, because they are one retirement (the element layer's second data door) + // and an upgrader moves them together. It absorbs the two protocol-18 + // narrowings of the element flat `filter` to the rule array + // (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`): + // the key they narrowed is gone in the same major, and the rule array they + // prescribed is the binding's own form. The conversion follows each element's + // old rule, so what it cannot follow is listed as a TODO and judged here. + { + id: 'element-flat-data-binding-retired', + surface: + 'page.component properties of element:record_picker (object, filter, sort, limit), ' + + 'element:number (object, filter) and element:repeater (object, filter, sort, limit) — the flat ' + + 'data-binding keys beside the node-level dataSource', + replacement: + '`dataSource` on the component node — `{ object, view?, filter?, sort?, limit? }`, a sibling of ' + + '`type` rather than a key inside `properties` — the one binding each of the three elements reads. ' + + 'Each key moves unchanged: `properties: { object: \'deal\', limit: 20 }` becomes ' + + '`dataSource: { object: \'deal\', limit: 20 }`, and a filter keeps its rule-array form ' + + '`[{ field, operator, value }, ...]`. `element:number` reads `object` and `filter` only. With a ' + + '`view`, the view supplies the baseline, an explicit binding key overrides it, and the binding ' + + 'filter AND-combines with the view\'s.', + reason: + 'One node carried two doors onto one query, resolved by three different rules: the record picker ' + + 'let the binding win (its flat key was read only when the binding, or the saved view the binding ' + + 'named, supplied none), `element:number` resolved `object` binding-first and AND-combined the two ' + + 'filters, and the repeater read its flat keys alone and ignored the binding — while the ' + + 'component-props gate waived a missing flat `object` whenever `dataSource.object` was present, ' + + 'so a repeater bound only through `dataSource` passed validation and drew an empty list. The ' + + 'console moved all three elements onto the binding first, and in v18 the flat keys are ' + + 'refused. The D2 conversion `element-flat-data-binding-to-data-source` follows each element\'s ' + + 'old rule: a key the binding lacks moves there, a key the binding already set is deleted where ' + + 'the binding won, and `element:number`\'s filter is appended to the binding\'s. Three cases are ' + + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' + + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' + + 'wherever the mapping is lossless; that entry lists the rest. Code that builds these props — a ' + + 'host, a generator, a designer — must write the binding, which no conversion reaches. And the ' + + 'gate now requires `dataSource.object` on all three elements: a node with none names no object ' + + 'and is reported.', + acceptanceCriteria: + 'No `element:record_picker`, `element:number` or `element:repeater` node carries `object`, ' + + '`filter`, `sort` or `limit` inside `properties`; the parse refuses each. Every such node has ' + + '`dataSource.object`, and `os validate` reports no missing-binding finding for it. In the running ' + + 'page each picker offers, each number aggregates and each repeater lists the records the author ' + + 'intends — checked first on every node the migration listed as a TODO, and on every repeater ' + + 'that already carried a `dataSource`.', + conversionIds: ['element-flat-data-binding-to-data-source'], + }, // #9198 (ADR-0049 enforce-or-remove) — the D3 entry of the // `element-input-target-variable-removed` family. Every retirement family // carries one D3 entry even when a lossless D2 conversion repairs its data @@ -11503,116 +11587,6 @@ const step18: MigrationStep = { + 'whatever consumes it on the page — returns the value entered. No component authors ' + '`targetVariable`; the parse refuses it by name.', }, - { - 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 (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'align the element to the `ViewFilterRule` array rather than keep it the record-shaped ' - + "exception). `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): ' - + 'the console adapter was changed so `ObjectStackAdapter.aggregate()` runs the same ' - + '`translateFilterArray` ' - + 'its `find()` path runs, and the objectui pin carrying it was re-measured before this ' - + 'entry moved — but that measurement named the wrong hop, and the runtime route\'s refusal ' - + 'of the array corrects it here. ' - + '`translateFilterArray` yields AST tuples, which are still a `FilterArray` — input-only ' - + 'sugar — so the real path is: authored array → `translateFilterArray` → lowered by ' - + '`parseFilterAST` (`@objectstack/spec/data`, the single sink the `FilterArray` docblock ' - + 'names, since the maintainer\'s 2026-08-04 ruling C declared the array input-only sugar ' - + 'with one lowering seam) in the adapter, BEFORE the wire → a `FilterCondition` on the ' - + 'body. The hop that decides it is the runtime route `POST /analytics/query`, which ' - + 'parses `where` with `AnalyticsQueryRequestSchema` — a `FilterCondition` and nothing ' - + 'else — so an un-lowered array is refused there before any service code runs. ' - + '`lowerAnalyticsWhere` (`service-analytics`), where that earlier measurement stopped, ' - + 'is the IN-PROCESS door (added when an array `where` was found silently dropped on the ' - + 'analytics path) for callers reaching `analyticsService.query` ' - + "directly, not the wire's; it too still refuses a RAW rule-object array by design. " - + 'The adapter-side lowering lands in the console\'s own repository. ' - + '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 ' - + 'the console-side half of this convergence.', - }, - { - 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()`, a gap measured on its own). 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 (the maintainer\'s 2026-08-25 ruling, option B: ' - + 'every `filter` door takes the `ViewFilterRule` array rather than keeping record-shaped ' - + "exceptions). `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: 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()`, 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: measure the consumer\'s read path before the contract moves): ' - + '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 — ' - + 'a console-side change filed in the objectui repository, blocked on that release.', - }, // #21015 — release 2 of objectui#7450's ruling B: `element:text` `variant` // refuses the pre-convergence spellings `heading` and `subheading` by name // (`enumWithRetiredValues`). The family's one D3 entry; the D2 half is @@ -16280,82 +16254,40 @@ const step18: MigrationStep = { + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes the ' + 'objectui finding that the two authorities disagreed.', }, - // The key the one-filter-orthography convergence did not name. Its sibling - // entry element-data-source-and-object-block-filter-rule-array says so in as - // many words — 「object-grid.defaultFilters is a different key and is not named - // by the ruling this entry records」 — so this is the entry that names it. + // #11509 (v18, ruling A-narrow, sub-question 1) — the D3 entry of the + // `object-grid-default-filters-removed` family, in the shape the + // `object-grid-default-sort-retired` entry beside it took. It absorbs the + // protocol-18 narrowing of the same key (`object-grid-default-filters-rule-array`, + // never released in a major): #19514 narrowed `defaultFilters` to the rule array + // and left its removal to a ruling of its own, which this is. { - id: 'object-grid-default-filters-rule-array', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. + id: 'object-grid-default-filters-retired', surface: - 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' - + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' - + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' - + 'rules alike', + 'page.component.object-grid.defaultFilters — the legacy second spelling of the grid base filter', replacement: - 'the same ViewFilterRule array form its sibling filter takes — ' - + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' - + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' - + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' - + 'operator into the rule, becoming ' - + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' - + '[["owner_id", "=", "{current_user_id}"]] becomes ' - + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' - + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' - + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' - + 'is read only when filter is absent, and its own description has prescribed filter all ' - + 'along', - reason: - 'The protocol half of the maintainer\'s ruling C-prime of 2026-09-20 on objectui\'s ' - + 'render-time filter converter — the protocol is the only refusal set, so a document it ' - + 'accepts never throws at render time — verbatim, untranslated: 「the differences are the ' - + 'protocol\'s to close」. This is the SAME value ' - + 'in the SAME role as filter — the key\'s own description says it is read only when ' - + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' - + 'refusal that sink can give was reachable from a document the protocol had just ' - + 'accepted. filter converged on the rule array with the rest of its family; this key was ' - + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' - + 'said nothing about what the grid would then do with the value, and in the objectui ' - + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' - + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' - + 'tuple array were lowered and applied as declared; a bare string or a number was ' - + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' - + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' - + 'client before any request for the value shapes it judges itself. ' - + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' - + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' - + 'own ruling; the deprecation already stated in the description is unchanged and still ' - + 'says to prefer filter. ' - + 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to ' - + 'the rule array by the same D2 conversion as its sibling filter, ' - + 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by ' - + 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot ' - + 'map losslessly is left exactly as stored and keeps rendering as it does today — a ' - + 'combinator, a null value, an operator the rule vocabulary does not spell, or the bare ' - + 'string or number this key also took — and ' - + 'its door refuses such a value only as the component-props gate\'s advisory finding ' - + '(os validate, os build, os lint), since a re-save through the metadata API is not ' - + 'refused there: a record form with the message the filter door gives, a worked rewrite ' - + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' - + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' - + 'refusal. ADR-0049 / ADR-0087.', - acceptanceCriteria: - 'Every object-grid node in your pages either omits defaultFilters or carries a ' - + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' - + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' - + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. What to re-check ' - + 'depends on the shape that was there, as the objectui version this release pins treats ' - + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' - + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' - + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' - + 'show before writing the rule that selects them. A list of malformed rules was refused ' - + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' - + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' - + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' - + 'rules onto filter rather than deleting them.', + '`filter: [{ field, operator, value }, ...]` — the one base-filter key every read path honours; ' + + 'the same rules, unchanged.', + reason: + 'The grid read `defaultFilters` only when `filter` lowered to nothing, so one intent had two ' + + 'spellings on one block. The D2 conversion `object-grid-default-filters-removed` follows that ' + + 'precedence: where `filter` was empty (absent, null, `[]` or `{}`) the fallback WAS the grid\'s ' + + 'filter, so its rules move onto `filter`; where `filter` had rules the fallback was never read, ' + + 'so it is deleted. Both preserve what the grid showed, and the second is where the judgment ' + + 'sits: a grid that authored both keys has always listed the rows `filter` selects, while its ' + + 'author may believe `defaultFilters` applied. The conversion keeps the rows users have been ' + + 'seeing and discards the rules that were written; only the author can say which were meant. ' + + 'A `filter` that is neither empty nor rules (a bare string, a number) is left as stored and ' + + 'listed as a TODO: the grid fell back to `defaultFilters` there too, and moving it would ' + + 'overwrite what was written at `filter`. A fallback in the retired record form moves to `filter` ' + + 'and is then converted there by `page-component-filter-record-to-rule-array` wherever the mapping ' + + 'is lossless; that entry lists the rest. Code that builds object-grid props (a host, a ' + + 'generator) must also stop emitting the key, which no conversion reaches.', + acceptanceCriteria: + 'No `object-grid` component carries `defaultFilters`; the parse refuses it. Each grid\'s `filter` ' + + 'holds the rules the author intends, and the grid lists exactly the rows they select. For every ' + + 'grid that had authored both keys, the author has compared the discarded `defaultFilters` rules ' + + 'with the kept `filter` and confirmed the kept one.', + conversionIds: ['object-grid-default-filters-removed'], }, // #11805 (ADR-0049 enforce-or-remove) — the D3 entry of the // `object-grid-default-sort-removed` family (ruling B on #17152: one D3 entry @@ -27036,6 +26968,48 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the // component. 'ui/ElementFormProps:submitLabel', + // #11509 (v18, ruling A-narrow) — `filter` on `element:number` was a flat second + // spelling of the node-level `dataSource.filter` binding (the element resolved `object` binding-first and AND-combined the two filters). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementNumberProps:filter', + // #11509 (v18, ruling A-narrow) — `object` on `element:number` was a flat second + // spelling of the node-level `dataSource.object` binding (the element resolved `object` binding-first and AND-combined the two filters). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementNumberProps:object', + // #11509 (v18, ruling A-narrow) — `filter` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.filter` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:filter', + // #11509 (v18, ruling A-narrow) — `limit` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.limit` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:limit', + // #11509 (v18, ruling A-narrow) — `object` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.object` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:object', + // #11509 (v18, ruling A-narrow) — `sort` on `element:record_picker` was a flat second + // spelling of the node-level `dataSource.sort` binding (the picker resolved `dataSource. ?? properties.`, so the binding always won). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRecordPickerProps:sort', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on // `element:record_picker` was a declarative hint with zero readers: the picker // writes the selected record id through the reverse binding — the page @@ -27055,6 +27029,34 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // Sources are rewritten by the D2 conversion // `element-input-target-variable-removed`. 'ui/ElementRecordPickerProps:targetVariable', + // #11509 (v18, ruling A-narrow) — `filter` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.filter` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:filter', + // #11509 (v18, ruling A-narrow) — `limit` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.limit` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:limit', + // #11509 (v18, ruling A-narrow) — `object` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.object` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:object', + // #11509 (v18, ruling A-narrow) — `sort` on `element:repeater` was a flat second + // spelling of the node-level `dataSource.sort` binding (the list read the flat keys alone, and only objectui#11880 moved it onto the binding). + // In v18 an element binds data through `dataSource` only: one node, one + // door, one precedence. Sources are rewritten by the D2 conversion + // `element-flat-data-binding-to-data-source`; the judgment an upgrader + // still owes is the D3 entry `element-flat-data-binding-retired`. + 'ui/ElementRepeaterProps:sort', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on `element:text_input` // was a declarative hint with zero readers: its own describe text said the // live binding "resolves via the variable whose `source` equals this component @@ -27153,6 +27155,14 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // stripped key does not record. So the prescription reaches consumers as that // semantic TODO plus this tombstone. 'ui/NavigationConfig:view', + // #11509 (v18, ruling A-narrow, sub-question 1) — `defaultFilters` on + // `object-grid` was the legacy second spelling of `filter`, read only when + // `filter` lowered to nothing; #19514 narrowed it to the rule array and left + // the removal to its own ruling, which this is. Retired in the shape + // `defaultSort`'s took (`ui/ObjectGridProps:defaultSort`, beside this entry): + // the D2 conversion `object-grid-default-filters-removed` moves the rules onto + // an empty `filter` and deletes the key beside a `filter` that has content. + 'ui/ObjectGridProps:defaultFilters', // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer // half of objectui#5861, under the objectui#4869 「接受所有」 direction). diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 0e8ed804c3d..8bf719d97d7 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -231,9 +231,15 @@ import { INLINE_GRID_SORT_FIELD_LIST } from '../data/inline-grid-sort-fields'; // — fell outside it and stayed undeclared. Commit 78f0be872 declared them on the same // #5611 rule (maintainer ruling 2026-08-08, direction A). The lesson for the // next divergence sweep: enumerate by the RENDERER'S read pattern, not by the -// key list a previous ruling happened to quote. Retiring the flat family -// wholesale in favour of `dataSource` is the standing alternative, deferred to -// v18 as #11509 — not rejected. +// key list a previous ruling happened to quote. Retiring the flat family in +// favour of `dataSource` was the standing alternative, deferred to v18 as +// #11509 and ruled there (A-narrow): in v18 the ELEMENT layer's flat binding +// keys retire — `element:record_picker` `object` / `filter` / `sort` / +// `limit`, `element:number` `object` / `filter`, `element:repeater` `object` / +// `filter` / `sort` / `limit` — together with `object-grid.defaultFilters`, +// and an element binds data through the node-level `dataSource` only. The +// `object-*` blocks keep their native keys (`dataSource` is an overlay one gate +// maps onto them), and the relationship-scoped blocks stay as declared. // // ── #5068: THE GATE IS WIRED — read the flip precisely ───────────────────── // @@ -2594,39 +2600,99 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * The three elements whose flat data-binding keys retired in v18 (#11509, + * ruling A-narrow), and the keys each one carried. The single source for the + * tombstones below, the `element-flat-data-binding-to-data-source` conversion + * and the component-props gate's "no `dataSource.object`" refusal + * (`@objectstack/lint`), so the three cannot disagree about which element owes + * a binding. + */ +export const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +} as const satisfies Readonly>; + +/** An element whose query is the node-level `dataSource` binding only. */ +export type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; + +/** What the value of each retired key is — it moves unchanged. */ +const ELEMENT_FLAT_BINDING_VALUE = { + object: 'an object name', + filter: 'a ViewFilterRule array', + sort: 'a `[{ field, order }]` array', + limit: 'a positive integer', +} as const; + +/** + * One prescription per retired element-layer flat binding key. Generated from + * the element and the key rather than written ten times, so the ten strings + * differ only where the elements did: what the element read before, and what + * to do with a key the binding already sets — the record picker let the + * binding win, `element:number` AND-combined the two filters, and the repeater + * read the flat keys alone. + */ +const elementFlatBindingRetired = ( + type: RetiredFlatBindingElementType, + key: 'object' | 'filter' | 'sort' | 'limit', +): string => { + const why = type === 'element:repeater' + ? 'the list now reads its query from the node-level `dataSource` binding only' + : `it was a flat second spelling of the node-level \`dataSource.${key}\`, and the ` + + `${type === 'element:number' ? 'element' : 'picker'} now reads its query from \`dataSource\` only`; + const both = type === 'element:repeater' + ? 'where `dataSource` already sets it too, keep the value written here, which is the one the list honoured' + : type === 'element:number' && key === 'filter' + ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' + : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + + `${why}, so a value written here reaches no query. Use \`dataSource.${key}\` on the component ` + + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +}; + +/** + * The prescription for another spelling of a repeater query key (`objectName`, + * `where`, `top`, …): the key it meant is the binding's, one level up. + */ +const elementRepeaterBindingGuidance = ( + spelling: string, + key: 'object' | 'filter' | 'sort' | 'limit', +): string => + `\`${spelling}\` is not a prop of \`element:repeater\` — the list's query is the node-level ` + + `\`dataSource\` binding. Write it as \`dataSource.${key}\` on the component node, a sibling of ` + + '`type` rather than a key inside `properties`.'; + +/** + * `element:number` — one aggregate over one object's records. + * + * Its query is the node-level `dataSource` binding (`ElementDataSourceSchema`, + * page.zod.ts): `object`, an optional saved `view`, and `filter` rules, which + * AND with the view's. `sort` and `limit` mean nothing to an aggregate and are + * not read. A node with no `dataSource.object` names no object — the + * component-props gate (`@objectstack/lint`, `validate-component-props`) + * reports it, because the props schema below no longer can. + * + * REMOVED (#11509, v18, ruling A-narrow): the flat `object` / `filter` + * spellings of that binding. They were the same query read through a second + * door — `object` resolved `dataSource.object` first, while the flat `filter` + * AND-combined with the binding's (the OPPOSITE of the record picker's rule, a + * second dialect on one node) — and objectui reads `dataSource` only since + * objectui#11880. The protocol-18 conversion + * `element-flat-data-binding-to-data-source` moves both onto the binding. + */ export const ElementNumberPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:number`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { - object: z.string().describe('Source object'), + object: retiredKey(elementFlatBindingRetired('element:number', 'object')), field: z.string().optional().describe('Field to aggregate'), aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']) .describe('Aggregation function'), - /** - * Filter rules narrowing the aggregate — the `ViewFilterRule` ARRAY form, - * `[{ field, operator, value }, ...]`, the one filter orthography every - * other `filter` input in this map already declares (`record:related_list` - * and its Add-affordance picker). Until the ui#6206 ruling (2026-08-25, - * Option B, verbatim 「同意」: one filter orthography platform-wide) this - * entry alone said `FilterConditionSchema`, the MongoDB-style record form — - * so the filter a list view stores and renders was refused by the KPI - * element beside it. 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, and the pin carrying it (`d8ec8d6d`) was re-measured before - * this declaration moved — authored array → adapter lowering → filter AST → - * accepted at the analytics door (which still refuses a RAW rule-object - * 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, { - 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`'), + filter: retiredKey(elementFlatBindingRetired('element:number', 'filter')), format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), prefix: z.string().optional().describe('Prefix text (e.g. "$")'), suffix: z.string().optional().describe('Suffix text (e.g. "%")'), @@ -2641,6 +2707,9 @@ export type ElementNumberProps = z.input; * `ViewFilterRuleParsed` exists). So `element:number` leaves the type-alias * convention pin's isomorphic family (the Iso818 line deleted with this * alias), taking the `ObjectGridPropsParsed` route its comment prescribes. + * That `filter` retired in v18 (#11509, a tombstone now); the alias stays, + * because deleting a published type name is an export removal of its own, + * which that retirement does not make. */ export type ElementNumberPropsParsed = z.infer; @@ -3006,13 +3075,29 @@ export const ElementFormPropsSchema = lazySchema(() => strictObject({ * for its own `sort` / `limit`, deliberately: they are the SAME contract read * through a second spelling, so a divergent shape here would be a third * dialect rather than a shorthand. + * + * REMOVED in v18 (#11509, ruling A-narrow): all four flat shorthands — + * `object`, `filter`, `sort`, `limit` — direction B, landed for the element + * layer. Declaring `sort` / `limit` closed the trapdoor; retiring the four + * closes the second door itself: one node, one binding, one precedence (the + * binding's own — its `view` supplies the baseline, an explicit binding key + * overrides it, and its `filter` AND-combines with the view's). objectui reads + * the picker's query from `dataSource` only since objectui#11880, so a flat + * key would reach no query. The protocol-18 conversion + * `element-flat-data-binding-to-data-source` moves each flat key onto the + * binding where the binding lacks it, deletes it where the binding already + * set it (the binding always won), and leaves it for the author, as a + * reported TODO, beside a `dataSource.view` — whether the view's own key + * displaced it depends on the view, which no conversion reads. A picker with + * no `dataSource.object` names no object, and the component-props gate + * (`@objectstack/lint`) reports it. */ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:record_picker`', history: PROPS_HISTORY, guidanceSets: COMPONENT_LEVEL_GUIDANCE, }, { - object: z.string().describe('Object to pick records from'), + object: retiredKey(elementFlatBindingRetired('element:record_picker', 'object')), /** * Field rendered as each row's text. Defaults to `name`, which is what the * renderer falls back to (`props.labelField ?? 'name'`) — so this is @@ -3024,65 +3109,17 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ /** Control label rendered above the select. */ label: I18nLabelSchema.optional().describe('Control label rendered above the select'), /** - * Filter rules narrowing which records the picker offers — the - * `ViewFilterRule` ARRAY form, `[{ field, operator, value }, ...]`, the one - * filter orthography the map's array-declared `filter` doors share - * (`record:related_list`, its nested Add-affordance picker, and — since - * #12039 Key 2 — `element:number`; and, since #15449, the four `filter` - * doors of the six-entry `object-*` family — `object-grid`, - * `object-metric`, `object-kanban` and `object-calendar` — each of which - * declares this same `z.array(ViewFilterRuleSchema)`, while that family's - * remaining two entries, `object-form` and `object-master-detail-form`, - * declare no `filter` key at all). Until #14406 - * this entry alone still said `FilterConditionSchema`, the MongoDB-style - * record form: the last record-form `filter` in `ComponentPropsMap` after - * the ui#6206 ruling (2026-08-25, Option B, verbatim 「同意」: one filter - * orthography platform-wide). - * - * Sequenced measurement-first, as the `element:number` convergence had to - * be (the 2026-08-25 Option-A ordering ruling): the read path was measured - * at the objectui pin (`00d3f09c`) before this declaration moved. The - * renderer hands the value to `query.$filter` and calls `adapter.find()` - * (`components/src/renderers/basic/record-picker.tsx`); - * `ObjectStackAdapter.convertQueryParams` lowers an ARRAY `$filter` through - * `translateFilterArray` — `[{ field, operator, value }]` → 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 (`objectql/src/engine-filter-array-lowering.test.ts`). Nothing - * on that path parses `properties` against the installed spec, so no - * refusal stands between an authored array and the query. The record form - * is refused at `filter`; the migration prescription is the - * `element-record-picker-filter-rule-array` semantic entry. - * - * The binding-level `dataSource.filter` this shorthand yields to - * (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this - * entry's subject. + * REMOVED in v18 (#11509) with `object` above — the flat shorthands of + * `dataSource.filter` / `.sort` / `.limit`. The rule-array shape `filter` + * converged on (#14406, the `element-record-picker-filter-rule-array` entry + * this retirement absorbs) and the `sort` / `limit` declarations (commit + * 78f0be872) are history now: the binding carries the same shapes, and its + * `limit` falls back to the renderer's 50 when neither it nor its view caps + * the query. */ - 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 (commit 78f0be872). The flat shorthand for `dataSource.sort`, and the same - * shape — `SortItemSchema[]`, the pairs the renderer forwards to the query as - * `$orderby`. `dataSource.sort` wins when both are written - * (`ds.sort ?? props.sort`). - */ - sort: z.array(SortItemSchema).optional() - .describe('Row order — synonym of the component-level `dataSource.sort`, which takes precedence when both are set'), - /** - * Row cap (commit 78f0be872). The flat shorthand for `dataSource.limit`, same shape. - * `dataSource.limit` wins when both are written, and with neither the - * renderer queries `$top: 50` (`ds.limit ?? props.limit ?? 50`) — that 50 is - * the renderer's fallback, not a schema default, so it is documented here - * rather than declared: declaring it would materialize a `limit: 50` on every - * parsed picker and turn an unset key into an authored one. - */ - limit: z.number().int().positive().optional() - .describe('Max records offered — synonym of the component-level `dataSource.limit`, which takes precedence when both are set (renderer default 50)'), + filter: retiredKey(elementFlatBindingRetired('element:record_picker', 'filter')), + sort: retiredKey(elementFlatBindingRetired('element:record_picker', 'sort')), + limit: retiredKey(elementFlatBindingRetired('element:record_picker', 'limit')), /** * REMOVED (#9198). ADR-0049 enforce-or-remove: a declarative hint with zero * readers — the live binding runs the other direction, resolved from the @@ -3121,8 +3158,8 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ '`element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 ' + '(ADR-0049) — the picker renders a plain single-select with no search input, so no ' + 'renderer ever read it and it narrowed nothing. Delete the key. To restrict which records ' - + 'the picker offers, use `filter` (or the component-level `dataSource.filter`), which the ' - + 'query path does apply. ' + + 'the picker offers, use the component-level `dataSource.filter`, which the query path ' + + 'does apply. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', ), /** @@ -3147,6 +3184,9 @@ export type ElementRecordPickerProps = z.input; @@ -4114,24 +4154,47 @@ export type ElementDefinitionListProps = z.input strictObject({ surface: 'this `element:repeater`', history: elementListHistory('element:repeater'), guidanceSets: COMPONENT_LEVEL_GUIDANCE, - // The same four query keys the element data-source binding declares, with - // its spellings for them (`ElementDataSourceSchema`, page.zod.ts), plus the - // `object-*` family's `objectName`. - aliases: { - objectName: 'object', filters: 'filter', where: 'filter', - orderBy: 'sort', sortBy: 'sort', top: 'limit', pageSize: 'limit', + // The binding's own spellings for its query keys (`ElementDataSourceSchema`, + // page.zod.ts), plus the `object-*` family's `objectName`. Until v18 these + // were aliases of the flat keys; those keys are tombstones now, so each + // spelling points at the binding instead (an alias may only name a key the + // shape accepts — `alias-integrity.test.ts`). + guidance: { + objectName: elementRepeaterBindingGuidance('objectName', 'object'), + filters: elementRepeaterBindingGuidance('filters', 'filter'), + where: elementRepeaterBindingGuidance('where', 'filter'), + orderBy: elementRepeaterBindingGuidance('orderBy', 'sort'), + sortBy: elementRepeaterBindingGuidance('sortBy', 'sort'), + top: elementRepeaterBindingGuidance('top', 'limit'), + pageSize: elementRepeaterBindingGuidance('pageSize', 'limit'), }, }, { - object: z.string() - .describe('Object whose records the list repeats over — required: without it the list never queries'), + object: retiredKey(elementFlatBindingRetired('element:repeater', 'object')), titleField: z.string().optional() .describe('Field shown first on each line, emphasized'), fields: z.array(z.union([ @@ -4148,12 +4211,9 @@ export const ElementRepeaterPropsSchema = lazySchema(() => strictObject({ }), ])).optional() .describe('Fields shown after the title on each line, in order — a bare field name, or `{ field }`'), - filter: z.array(ViewFilterRuleSchema).optional() - .describe('Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares'), - sort: z.array(SortItemSchema).optional() - .describe('Sort order — `[{ field, order }]`'), - limit: z.number().int().positive().optional() - .describe('Maximum records fetched and shown'), + filter: retiredKey(elementFlatBindingRetired('element:repeater', 'filter')), + sort: retiredKey(elementFlatBindingRetired('element:repeater', 'sort')), + limit: retiredKey(elementFlatBindingRetired('element:repeater', 'limit')), emptyText: z.string().optional() .describe('Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id'), divided: z.boolean().optional() @@ -4165,7 +4225,10 @@ export type ElementRepeaterProps = z.input; * ADR-0122: the parsed state differs from the authored state on exactly one * key — `filter` carries `ViewFilterRuleSchema`, whose `operator` is * normalized on parse (why `ViewFilterRuleParsed` exists), the route - * `element:number` and `element:record_picker` took. + * `element:number` and `element:record_picker` took. That `filter` retired in + * v18 (#11509, a tombstone now); the alias stays, because deleting a + * published type name is an export removal of its own, which that retirement + * does not make. */ export type ElementRepeaterPropsParsed = z.infer; @@ -4313,8 +4376,8 @@ const GridOperationsSchema = lazySchema(() => strictObject({ * `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`). * Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715), * `filter` (:739, lowered via `toFilterNode` to `$filter`), `defaultFilters` - * (:922 — the LEGACY fallback read only when `filter` is absent; it is read, - * so it stays declared — only the plural `filters` has zero read points), + * (:922 — the LEGACY fallback read only when `filter` is absent; RETIRED in + * v18, #11509, tombstoned below — the plural `filters` never had a read point), * `sort` (:741) / `defaultSort` (:943 — RETIRED #11805, tombstoned below; * objectui#5861 retires the read), `pagination`/`pageSize`/`showPagination` * (:567, :752, :2475-2480), `searchableFields`/`showSearch` (:959, :2484-2486), @@ -4529,44 +4592,31 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ }).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`'), /** - * [#19514] The legacy base-filter fallback — the SAME value in the SAME role - * as `filter` above, so it carries the same declaration. - * - * Its own description has said "read only when `filter` is absent" since the - * key entered this map (#7751), which is a statement that the two keys hold - * one kind of value: objectui's `ObjectGrid` reads this one through the same - * lowering sink it reads `filter` through, so every refusal that sink can - * give is reachable from a document that passed the protocol. While `filter` - * was narrowed to the rule array and this stayed `z.unknown()`, the block had - * a declared door and an undeclared one onto the same seam — a bare string, a - * number, a MongoDB-style record and an ObjectQL AST tuple array all parsed - * here, and the author's receipt said nothing about what the grid would do - * with them. At the objectui `.objectui-sha` pin `87af769e9a` - * (`ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record - * form and the tuple array are lowered and APPLIED as declared; a bare string - * or a number is DROPPED, so the grid sends no filter and lists its rows - * unfiltered; and a list of malformed rules is REFUSED — on the wire with - * 400 `INVALID_FILTER`, or by the client before any request for the value - * shapes it judges itself. - * - * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this - * could have taken and it is a REMOVAL of an accepted shape, which needs its - * own ruling. The deprecation stated in the description stands - * exactly where it stood — prefer `filter` — and is unchanged by this. + * REMOVED in v18 (#11509, ruling A-narrow, sub-question 1 — the shape + * `defaultSort`'s retirement took, below). The legacy base-filter fallback: + * the SAME value in the SAME role as `filter`, read only when `filter` + * lowered to nothing, so one intent had two spellings on one block. #19514 + * narrowed it to the rule array ("narrowed, NOT retired — refusing the key + * outright needs its own ruling"); #11509 is that ruling, and the narrowing's + * D3 entry (`object-grid-default-filters-rule-array`, never released in a + * major) is absorbed into this retirement's. * - * The `{ error }` map is `filter`'s, deliberately: an author who wrote the - * record form here needs the same conversion table, computed from their own - * keys, and a second hand-written sentence at this door is the drift - * `ruleArrayFilterError` exists to prevent. Its `surface` names which key was - * written, because the message's own subject is `filter`. + * The live mechanism is `filter`. The protocol-18 conversion + * `object-grid-default-filters-removed` carries the mechanical rewrite: when + * `filter` is empty (absent, `null`, `[]` or `{}`) the rules move into it; + * when `filter` has content the key is deleted, since the fallback was never + * read then. It runs before `page-component-filter-record-to-rule-array`, so + * a record-form value it moves is converted at `filter` like any other. */ - defaultFilters: z.array(ViewFilterRuleSchema, { - error: ruleArrayFilterError({ - surface: 'this `object-grid` (you wrote it on the `defaultFilters` fallback, which takes the same form)', - migration: 'object-grid-default-filters-rule-array', - }), - }).optional() - .describe('Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array`'), + defaultFilters: retiredKey( + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' + + '`[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this ' + + 'key: the grid never read it there. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', + ), /** * Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`, * the one sort orthography every DECLARED `sort` door on this platform From 359e1ee94a964bfc3c8582d0339aebc9171c7948 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:26:36 +0000 Subject: [PATCH 02/14] wip(spec): regenerate the surfaces the element-binding retirement moves Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 82 +++---------------- packages/spec/api-surface/ui.json | 2 + packages/spec/authorable-surface/ui.json | 22 ++--- .../spec/dropped-refinements.baseline.json | 15 ---- packages/spec/export-origins/ui.json | 2 + 5 files changed, 27 insertions(+), 96 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index e56b72e0d3c..6c3eb738424 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -341,25 +341,15 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Source object | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **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` | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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; 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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - ### Nested Shape: `ElementNumberProps.aria` | Property | Type | Required | Description | @@ -377,40 +367,21 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object to pick records from | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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` | optional | Control label rendered above the select | -| **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) | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | | **displayField** | `never` | optional | [REMOVED] `element:record_picker` property `displayField` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — it was a required declaration no renderer ever read, while the renderer honoured `labelField` for the same thing and defaulted to `name`. Rename the key to `labelField`; the value (a field name) is unchanged. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use `filter` (or the component-level `dataSource.filter`), which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **searchFields** | `never` | optional | [REMOVED] `element:record_picker` property `searchFields` was removed in @objectstack/spec 17.0.0 (ADR-0049) — the picker renders a plain single-select with no search input, so no renderer ever read it and it narrowed nothing. Delete the key. To restrict which records the picker offers, use the component-level `dataSource.filter`, which the query path does apply. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **aria** | `{ ariaLabel?: string \| Record; 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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - -### Nested Shape: `ElementRecordPickerProps.sort[number]` - -Sort field and direction pair - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to sort by | -| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | - ### Nested Shape: `ElementRecordPickerProps.aria` | Property | Type | Required | Description | @@ -428,12 +399,12 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `string` | ✅ | Object whose records the list repeats over — required: without it the list never queries | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter rules narrowing the records — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` in this map shares | -| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order — `[{ field, order }]` | -| **limit** | `integer` | optional | Maximum records fetched and shown | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -443,25 +414,6 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name | -### Nested Shape: `ElementRepeaterProps.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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - -### Nested Shape: `ElementRepeaterProps.sort[number]` - -Sort field and direction pair - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name to sort by | -| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction | - --- @@ -845,7 +797,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **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` | -| **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | @@ -915,16 +867,6 @@ View filter rule | **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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | -### Nested Shape: `ObjectGridProps.defaultFilters[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. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | - ### Nested Shape: `ObjectGridProps.sort[number]` Sort field and direction pair diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0b28904473a..9de1a5bce4b 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -353,6 +353,7 @@ "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", + "RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", @@ -400,6 +401,7 @@ "ResolvedActionParam (interface)", "ResponsiveStyles (type)", "ResponsiveStylesSchema (const)", + "RetiredFlatBindingElementType (type)", "RowColorConfig (type)", "RowColorConfigSchema (const)", "RowHeight (type)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index a3aaf5ca157..446bbbbaa12 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -471,32 +471,32 @@ "ui/ElementNumberProps:aggregate", "ui/ElementNumberProps:aria", "ui/ElementNumberProps:field", - "ui/ElementNumberProps:filter", + "ui/ElementNumberProps:filter [RETIRED]", "ui/ElementNumberProps:format", - "ui/ElementNumberProps:object", + "ui/ElementNumberProps:object [RETIRED]", "ui/ElementNumberProps:prefix", "ui/ElementNumberProps:suffix", "ui/ElementRecordPickerProps:aria", "ui/ElementRecordPickerProps:displayField [RETIRED]", "ui/ElementRecordPickerProps:emptyText", - "ui/ElementRecordPickerProps:filter", + "ui/ElementRecordPickerProps:filter [RETIRED]", "ui/ElementRecordPickerProps:label", "ui/ElementRecordPickerProps:labelField", - "ui/ElementRecordPickerProps:limit", + "ui/ElementRecordPickerProps:limit [RETIRED]", "ui/ElementRecordPickerProps:multiple [RETIRED]", - "ui/ElementRecordPickerProps:object", + "ui/ElementRecordPickerProps:object [RETIRED]", "ui/ElementRecordPickerProps:placeholder", "ui/ElementRecordPickerProps:searchFields [RETIRED]", - "ui/ElementRecordPickerProps:sort", + "ui/ElementRecordPickerProps:sort [RETIRED]", "ui/ElementRecordPickerProps:targetVariable [RETIRED]", "ui/ElementRecordPickerProps:valueField", "ui/ElementRepeaterProps:divided", "ui/ElementRepeaterProps:emptyText", "ui/ElementRepeaterProps:fields", - "ui/ElementRepeaterProps:filter", - "ui/ElementRepeaterProps:limit", - "ui/ElementRepeaterProps:object", - "ui/ElementRepeaterProps:sort", + "ui/ElementRepeaterProps:filter [RETIRED]", + "ui/ElementRepeaterProps:limit [RETIRED]", + "ui/ElementRepeaterProps:object [RETIRED]", + "ui/ElementRepeaterProps:sort [RETIRED]", "ui/ElementRepeaterProps:titleField", "ui/ElementTextInputProps:aria", "ui/ElementTextInputProps:defaultValue", @@ -863,7 +863,7 @@ "ui/ObjectGridProps:columns", "ui/ObjectGridProps:conditionalFormatting", "ui/ObjectGridProps:data", - "ui/ObjectGridProps:defaultFilters", + "ui/ObjectGridProps:defaultFilters [RETIRED]", "ui/ObjectGridProps:defaultSort [RETIRED]", "ui/ObjectGridProps:description", "ui/ObjectGridProps:editable", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a2da918e93c..b7924a1675d 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -1277,21 +1277,6 @@ "filter.element" ] }, - "ui/ElementNumberProps": { - "sites": [ - "filter.element" - ] - }, - "ui/ElementRecordPickerProps": { - "sites": [ - "filter.element" - ] - }, - "ui/ElementRepeaterProps": { - "sites": [ - "filter.element" - ] - }, "ui/FormSection": { "sites": [ "in" diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 26e94e5a860..4006e862ac9 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -347,6 +347,7 @@ "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", + "RETIRED_ELEMENT_FLAT_BINDING_KEYS": "src/ui/component.zod.ts#RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)", "ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)", @@ -385,6 +386,7 @@ "ResolvedActionParam": "src/ui/action-params.zod.ts#ResolvedActionParam (interface)", "ResponsiveStyles": "src/ui/responsive.zod.ts#ResponsiveStyles (type)", "ResponsiveStylesSchema": "src/ui/responsive.zod.ts#ResponsiveStylesSchema (const)", + "RetiredFlatBindingElementType": "src/ui/component.zod.ts#RetiredFlatBindingElementType (type)", "RowColorConfig": "src/ui/view.zod.ts#RowColorConfig (type)", "RowColorConfigSchema": "src/ui/view.zod.ts#RowColorConfigSchema (const)", "RowHeight": "src/ui/view.zod.ts#RowHeight (type)", From 749cbc900174907d75263238f2e1fdd4f6bf6dd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:29:56 +0000 Subject: [PATCH 03/14] wip(lint)!: the component-props gate requires dataSource.object on the three data-source-bound elements Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../lint/src/validate-component-props.test.ts | 230 ++++++++++-------- packages/lint/src/validate-component-props.ts | 80 +++--- .../lint/src/validate-filter-tokens.test.ts | 4 +- .../src/validate-page-field-bindings.test.ts | 3 +- .../src/validate-print-page-blocks.test.ts | 2 +- packages/spec/scripts/check-yaml-examples.ts | 93 +++++-- packages/spec/src/conversions/registry.ts | 10 +- packages/spec/src/ui/component.zod.ts | 16 +- 8 files changed, 273 insertions(+), 165 deletions(-) diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 97a843a0586..3315e8caf44 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -12,10 +12,12 @@ // something. import { describe, expect, it } from 'vitest'; import { normalizeStackInput } from '@objectstack/spec'; +import { ComponentPropsMap } from '@objectstack/spec/ui'; import { validateComponentProps, COMPONENT_PROPS_UNKNOWN_KEY, COMPONENT_PROPS_INVALID, + DATA_SOURCE_BOUND_ELEMENT_TYPES, } from './validate-component-props.js'; import { runAuthoringRules, AUTHORING_RULES } from './authoring-rules.js'; @@ -289,85 +291,118 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * `ElementDataSourceSchema` is the component-node binding that "overrides - * page-level object context", and objectui's element renderers read it FIRST - * (`ds.object ?? props.object`). A component that binds through it has not - * omitted the flat shorthand — so reporting the props schema's required - * `object` here would be a WRONG verdict, not a strict one. + * #11509 (v18, ruling A-narrow) — the waiver turned into a refusal. This + * rule used to WAIVE the props schema's required flat `object` whenever the + * node's `dataSource.object` was present, for every type alike; the flat + * binding keys of the three data-source-bound elements are retired now, and + * the requirement sits on the binding the renderers actually read. One of + * the three with no `dataSource.object` is a `component-props-invalid` + * finding AT that path — the rule's existing finding, not a new rule. */ - it('does not report the required `object` prop when `dataSource` supplies it', () => { - const withDataSource = validateComponentProps( - stackWith([ - { - type: 'element:record_picker', - id: 'picker', - dataSource: { object: 'project', limit: 50 }, - properties: { labelField: 'name' }, - }, - ]), - ); - expect(withDataSource).toEqual([]); + describe('the data-source-bound elements owe `dataSource.object` (#11509)', () => { + const BOUND = ['element:record_picker', 'element:number', 'element:repeater'] as const; + /** The props each element needs to parse clean on its own, binding aside. */ + const PROPS: Record<(typeof BOUND)[number], Record> = { + 'element:record_picker': { labelField: 'name' }, + 'element:number': { aggregate: 'count' }, + 'element:repeater': { titleField: 'subject' }, + }; + + it.each(BOUND)('%s with its object on the binding: no finding', (type) => { + const findings = validateComponentProps( + stackWith([{ type, dataSource: { object: 'deal' }, properties: PROPS[type] }]), + ); + expect(findings).toEqual([]); + }); - // …and still reports it when nothing supplies it (or the suppression above - // would be indistinguishable from the rule never looking). - const without = validateComponentProps( - stackWith([{ type: 'element:record_picker', properties: { labelField: 'name' } }]), - ); - expect(invalid(without).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.object', - ]); - }); + it.each(BOUND)('%s with no binding: one finding, at `dataSource.object`', (type) => { + const findings = validateComponentProps(stackWith([{ type, properties: PROPS[type] }])); + expect(findings.map((f) => [f.rule, f.path, f.severity])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].dataSource.object', 'warning'], + ]); + expect(findings[0]!.message).toContain(`\`${type}\` reads its records from the node-level \`dataSource\` binding`); + expect(findings[0]!.hint).toContain('dataSource: {'); + }); - /** - * The waiver above covers a MISSING `object` only — no key, or `undefined`. - * A present value the row rejects is the author's own, and the binding - * supplies nothing in its place, so the row's verdict on it must reach the - * author exactly as it does with no binding at all. Each case is judged - * twice, beside the binding and without it, and the two answers must be the - * same finding: equality rather than wording, so the pin measures that the - * waiver lets the row's issue through unchanged. - */ - it.each([ - ['a number', 7], - ['null', null], - ])('reports a present-but-wrong `object` (%s) beside a `dataSource` binding, as it does without one', (_label, value) => { - const component = { type: 'element:number', properties: { object: value, aggregate: 'count' } }; - const withBinding = validateComponentProps( - stackWith([{ ...component, dataSource: { object: 'contact' } }]), - ); - const without = validateComponentProps(stackWith([component])); + it.each([ + ['a binding with no object', { view: 'hot_deals' }], + ['an empty object name', { object: '' }], + ['a non-string object', { object: 7 }], + ])('a binding that names no object (%s) is the same finding', (_label, dataSource) => { + const findings = validateComponentProps( + stackWith([{ type: 'element:number', dataSource, properties: { aggregate: 'count' } }]), + ); + expect(invalid(findings).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + ]); + }); - // Control first: with nothing supplying `object`, the row reports it. - expect(without.map((f) => [f.rule, f.path])).toEqual([ - [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], - ]); - // The binding does not silence it. - expect(withBinding.map((f) => [f.rule, f.path])).toEqual([ - [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], - ]); - expect(withBinding).toEqual(without); - }); + it('a node with no `properties` bag at all is judged too — the binding is a key of the node', () => { + const findings = validateComponentProps(stackWith([{ type: 'element:repeater' }])); + expect(invalid(findings).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + ]); + }); - it('still waives `object: undefined` beside a binding — an explicit undefined is a missing value', () => { - const findings = validateComponentProps( - stackWith([ - { - type: 'element:number', - dataSource: { object: 'contact' }, - properties: { object: undefined, aggregate: 'count' }, - }, - ]), - ); - expect(findings).toEqual([]); + it('control: an element outside the set owes no binding, and the old waiver is gone with the flat `object`', () => { + // `element:metadata_viewer` declares an `object` of its own (the metadata + // owner), unrelated to any binding — no binding is required of it. + const findings = validateComponentProps( + stackWith([ + { type: 'element:text', properties: { content: 'Hi' } }, + { type: 'element:metadata_viewer', properties: { type: 'flow', name: 'approve_deal' } }, + ]), + ); + expect(findings).toEqual([]); + }); - // …and the same bag with no binding is reported, so the silence above is - // the waiver and not the row accepting `undefined`. - const without = validateComponentProps( - stackWith([{ type: 'element:number', properties: { object: undefined, aggregate: 'count' } }]), - ); - expect(invalid(without).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.object', - ]); + /** + * THE REPEATER TRAP, closed from both sides. Before: a repeater bound only + * through `dataSource` passed this rule (the type-blind waiver) while its + * renderer read the flat keys alone and drew "No records"; the measured + * reading was 0 findings against the control's 1. After: that node is the + * clean shape (the renderer reads the binding, objectui#11880), and the + * node the old renderer DID read — a flat `object` — is refused twice: the + * tombstone at the key, with its prescription, and the missing binding. + */ + it('the repeater trap: bound only through `dataSource` is clean; aimed by a flat `object` is refused', () => { + const bound = validateComponentProps( + stackWith([{ type: 'element:repeater', dataSource: { object: 'deal_note', limit: 5 }, properties: { titleField: 'subject' } }]), + ); + expect(bound).toEqual([]); + + const flat = validateComponentProps( + stackWith([{ type: 'element:repeater', properties: { object: 'deal_note', titleField: 'subject' } }]), + ); + expect(invalid(flat).map((f) => f.path).sort()).toEqual([ + 'pages[0].regions[0].components[0].dataSource.object', + 'pages[0].regions[0].components[0].properties.object', + ]); + const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); + }); + + /** + * The gate keeps its own copy of the set, because the spec publishes none + * (an export would widen a retirement that only narrows). This pin derives + * the set from `ComponentPropsMap` — every row whose flat `object` is a + * tombstone pointing at `dataSource.object` — so a fourth element retired + * the same way, or one of the three un-retired, reds here. + */ + it('the set is the spec rows whose flat `object` is retired onto the binding', () => { + const derived = Object.entries(ComponentPropsMap) + .filter(([, schema]) => { + const parsed = (schema as { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }) + .safeParse({ object: 'probe' }); + return !parsed.success && parsed.error!.issues.some((i) => + i.path.length === 1 && i.path[0] === 'object' + && i.message.includes('was removed') && i.message.includes('`dataSource.object`')); + }) + .map(([type]) => type) + .sort(); + expect(derived).toEqual([...BOUND].sort()); + expect([...DATA_SOURCE_BOUND_ELEMENT_TYPES].sort()).toEqual(derived); + }); }); /** @@ -382,7 +417,8 @@ describe('validateComponentProps — value verdicts', () => { stackWith([ { type: 'element:record_picker', - properties: { object: 'project', displayField: 'name' }, + dataSource: { object: 'project' }, + properties: { displayField: 'name' }, }, ]), ); @@ -412,15 +448,15 @@ describe('validateComponentProps — value verdicts', () => { }); /** - * #6276 — the #5068 worklist entry #5775's key-by-key ruling left behind. - * The picker's renderer reads FOUR keys through one `ds. ?? props.` - * pattern; two of the flat spellings were declared and two were not, so this - * gate reported `sort`/`limit` as undeclared while the renderer honoured - * them. Both halves of the rule are asserted, because they fail differently: - * the key must stop being an unknown-key finding, and its VALUE must now be - * judged (before the declaration a wrong `limit` was stripped in silence). + * #6276 declared the picker's flat `sort` / `limit` beside `object` / + * `filter`, because the renderer read all four through one + * `ds. ?? props.` pattern. #11509 (v18) retired the four: the + * renderer reads the binding alone since objectui#11880. Each flat key is + * now a tombstone whose prescription reaches the author through this rule's + * value verdict — whatever value was written, a valid one included — and + * the missing binding is its own finding beside them. */ - it('reports nothing on the flat `sort` / `limit` shorthands the picker honours (#6276)', () => { + it('refuses the picker\'s four retired flat binding keys with their prescriptions (#11509)', () => { const findings = validateComponentProps( stackWith([ { @@ -429,28 +465,28 @@ describe('validateComponentProps — value verdicts', () => { properties: { object: 'showcase_project', labelField: 'name', + filter: [{ field: 'status', operator: 'equals', value: 'active' }], sort: [{ field: 'created_at', order: 'desc' }], limit: 20, }, }, ]), ); - expect(findings).toEqual([]); - }); - - it('now judges the VALUE of a flat `limit` instead of stripping it (#6276)', () => { - const findings = validateComponentProps( - stackWith([ - { - type: 'element:record_picker', - properties: { object: 'showcase_project', limit: 'twenty' }, - }, - ]), - ); - expect(invalid(findings).map((f) => f.path)).toEqual([ - 'pages[0].regions[0].components[0].properties.limit', + const base = 'pages[0].regions[0].components[0]'; + expect(invalid(findings).map((f) => f.path).sort()).toEqual([ + `${base}.dataSource.object`, + `${base}.properties.filter`, + `${base}.properties.limit`, + `${base}.properties.object`, + `${base}.properties.sort`, ]); - expect(invalid(findings)[0].message).toContain('expected number, received string'); + for (const key of ['object', 'filter', 'sort', 'limit']) { + const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; + expect(at.message).toMatch( + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), + ); + } + expect(unknownKeys(findings)).toEqual([]); }); /** diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index dfcabca527a..5c1af337036 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -152,36 +152,37 @@ interface PropsSchema { const PROPS_SCHEMAS = ComponentPropsMap as unknown as Record; /** - * The one prop whose absence this rule does NOT report when the component - * carries a per-element `dataSource`. + * The elements whose query is the node-level `dataSource` binding ONLY — the + * three whose flat binding keys (`object` and `filter`, and on two of them + * `sort` / `limit`) retired in v18 (#11509, ruling A-narrow). The spec keeps + * its own list of them private (publishing it would widen a retirement that + * only narrows), so this copy is pinned instead: + * `validate-component-props.test.ts` derives the set from `ComponentPropsMap` + * — every row whose flat `object` is a tombstone pointing at + * `dataSource.object` — and holds this one equal to it. * - * `ElementDataSourceSchema` is declared on the component node as the binding - * that "overrides page-level object context", and objectui's element renderers - * read it FIRST (`const object = ds.object ?? props.object`) — the same - * precedence `page-walk.ts` encodes for every rule built on it. The props - * schemas declare `object` as required because it is the flat shorthand; a - * component that binds through the richer sibling has not omitted anything. - * Reporting it would be the rule judging one half of a two-key contract, which - * is a wrong verdict rather than a strict one — the showcase's - * `element:record_picker` (`dataSource: { object: 'showcase_project', limit: 50 }`) - * is the live specimen. + * Until that retirement this rule WAIVED the props schema's required flat + * `object` whenever `dataSource.object` was present — for every component + * type alike, on the reading that the element renderers resolve + * `dataSource.object` first. The waiver was type-blind, and on + * `element:repeater`, whose renderer read the flat keys alone, it passed a + * list bound only through `dataSource` that queried nothing and drew "No + * records". The retirement turns the waiver into a refusal on the binding the + * renderers actually read: one of these elements with no `dataSource.object` + * is a `component-props-invalid` finding at that path — the same rule id and + * tier as every other value verdict here, not a new gate. A flat `object` + * beside it is the tombstone's own finding (the parse below), with its + * prescription. */ -const DATASOURCE_SUPPLIED_PROP = 'object'; +export const DATA_SOURCE_BOUND_ELEMENT_TYPES: ReadonlySet = new Set([ + 'element:record_picker', + 'element:number', + 'element:repeater', +]); -/** - * Is this issue "the required `object` prop is missing", on a component whose - * `dataSource` supplies it? - */ -function suppliedByDataSource(issue: LintZodIssue, component: AnyRec): boolean { - if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; - const dataSource = isRec(component.dataSource) ? component.dataSource : undefined; - if (strName(dataSource?.object) === undefined) return false; - // "Missing" is read off the component, never off the issue: the path alone - // also matches a PRESENT value the row rejects (`object: 7`, `object: null`), - // and the binding supplies nothing there — the author wrote that value and - // the row's own verdict on it stands. Only no key, or `undefined`, is waived. - const props = isRec(component.properties) ? component.properties : undefined; - return props?.[DATASOURCE_SUPPLIED_PROP] === undefined; +/** The object this component's node-level binding names, if it names one. */ +function boundObject(component: AnyRec): string | undefined { + return isRec(component.dataSource) ? strName(component.dataSource.object) : undefined; } /** @@ -257,9 +258,31 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { : isRec(component.properties) ? component.properties : undefined; + const where = `page "${pageName}" · ${type}`; + + // ── The binding an element reads ───────────────────────────────── + // Judged before the props bag, and whatever the bag holds: the binding + // is a key of the NODE, so a malformed bag does not excuse a missing one. + if (DATA_SOURCE_BOUND_ELEMENT_TYPES.has(type) && boundObject(component) === undefined) { + findings.push({ + severity: 'warning', + rule: COMPONENT_PROPS_INVALID, + where, + path: `${path}.dataSource.object`, + message: + `dataSource.object: \`${type}\` reads its records from the node-level \`dataSource\` binding ` + + 'only, and this node names no object there, so it queries nothing and draws its empty state ' + + 'as if the object had no rows — nothing refuses it today, so the renderer receives the node ' + + 'as written', + hint: + `Name the object on the component node — \`dataSource: { object: '' }\`, a ` + + 'sibling of `type`, not a key inside `properties`, where a flat `object` is retired and read ' + + 'by nothing.', + }); + } + if (!props) continue; - const where = `page "${pageName}" · ${type}`; const base = `${path}.properties`; // ── Undeclared keys ────────────────────────────────────────────── @@ -292,7 +315,6 @@ export function validateComponentProps(stack: AnyRec): ComponentPropsFinding[] { const parsed = schema.safeParse(props); if (parsed.success) continue; for (const issue of parsed.error?.issues ?? []) { - if (suppliedByDataSource(issue, component)) continue; const at = issue.path.length ? `${base}.${issue.path.join('.')}` : base; // A strict UNION ARM reports the same fact one layer in — see // `unrecognizedKeysFromUnionArm`. Routed to the unknown-key rule id diff --git a/packages/lint/src/validate-filter-tokens.test.ts b/packages/lint/src/validate-filter-tokens.test.ts index 3a76bc230d8..75db4c11916 100644 --- a/packages/lint/src/validate-filter-tokens.test.ts +++ b/packages/lint/src/validate-filter-tokens.test.ts @@ -246,7 +246,7 @@ describe('validateFilterTokens — {record_id}', () => { { name: 'main', components: [ - { type: 'element:number', properties: { object: 'task', aggregate: 'count', filter } }, + { type: 'element:number', dataSource: { object: 'task', filter }, properties: { aggregate: 'count' } }, ], }, ], @@ -312,7 +312,7 @@ describe('validateFilterTokens — {record_id}', () => { it.each(['home', 'app', 'utility', 'list'])('refuses it on a %s page', (type) => { expectRefusal( validateFilterTokens({ pages: [recordPage(type, { assignee: '{record_id}' })] }), - 'pages[0].regions[0].components[0].properties.filter.assignee', + 'pages[0].regions[0].components[0].dataSource.filter.assignee', 'page "person_page"', ); }); diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 6d157a4212c..e6f6f3d741c 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -82,7 +82,8 @@ describe('validatePageFieldBindings — highlights / KPI cards', () => { pages: [pageWith([ { type: 'element:number', - properties: { object: 'crm_account', field: 'amount', aggregate: 'sum' }, + dataSource: { object: 'crm_account' }, + properties: { field: 'amount', aggregate: 'sum' }, }, ])], }); diff --git a/packages/lint/src/validate-print-page-blocks.test.ts b/packages/lint/src/validate-print-page-blocks.test.ts index 6b1501654ac..08781eba2ea 100644 --- a/packages/lint/src/validate-print-page-blocks.test.ts +++ b/packages/lint/src/validate-print-page-blocks.test.ts @@ -117,7 +117,7 @@ describe('admits a print page built only from printable blocks', () => { { type: 'record:highlights', properties: { fields: ['name', 'invoice_date', 'due_date'] } }, { type: 'record:details', properties: { fields: ['customer', 'billing_address'] } }, { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount', type: 'currency' }], readonly: true } }, - { type: 'element:number', properties: { object: 'invoice_line', field: 'amount', aggregate: 'sum' } }, + { type: 'element:number', dataSource: { object: 'invoice_line' }, properties: { field: 'amount', aggregate: 'sum' } }, ], }, { name: 'footer', components: [{ type: 'element:divider' }, { type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, diff --git a/packages/spec/scripts/check-yaml-examples.ts b/packages/spec/scripts/check-yaml-examples.ts index 8bf1f1f476d..05ddfae5b1c 100644 --- a/packages/spec/scripts/check-yaml-examples.ts +++ b/packages/spec/scripts/check-yaml-examples.ts @@ -590,23 +590,24 @@ function isPlainRecord(v: unknown): v is Record { const joinKey = (base: string, key: string) => (base ? `${base}.${key}` : key); /** - * The one prop whose absence is NOT reported when the component carries a - * per-element `dataSource` — ported deliberately from - * `validate-component-props.ts`, which carries the full reasoning: the props - * schemas declare `object` as the flat shorthand, objectui's element renderers - * read `dataSource.object` first (`const object = ds.object ?? props.object`), - * and a component that binds through the richer sibling has omitted nothing. - * `content/docs/ui/pages.mdx` and `deployment/validating-metadata.mdx` both - * teach that precedence, so this is a shape the tagged corpus can grow at any - * time — and reporting it would be a WRONG verdict, not a strict one. + * The elements whose query is the node-level `dataSource` binding ONLY — the + * twin of `validate-component-props.ts`'s `DATA_SOURCE_BOUND_ELEMENT_TYPES`, + * which carries the full reasoning, turned with it (#11509, ruling A-narrow): + * their flat binding keys retired in v18, so one of these nodes with no + * `dataSource.object` names no object and is reported here at that path, + * where this gate used to WAIVE the flat `object` for any type whose binding + * named one. The self-test holds this set equal to the rows of + * `ComponentPropsMap` whose flat `object` is a tombstone pointing at + * `dataSource.object`, so it cannot drift from the spec on its own. */ -const DATASOURCE_SUPPLIED_PROP = 'object'; - -function suppliedByDataSource( - issue: { path: ReadonlyArray }, - node: Record, -): boolean { - if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; +const DATA_SOURCE_BOUND_ELEMENT_TYPES: ReadonlySet = new Set([ + 'element:record_picker', + 'element:number', + 'element:repeater', +]); + +/** Does this node's own `dataSource` binding name an object? */ +function namesBoundObject(node: Record): boolean { const dataSource = isPlainRecord(node.dataSource) ? node.dataSource : undefined; return typeof dataSource?.object === 'string' && dataSource.object.length > 0; } @@ -649,11 +650,16 @@ function collectComponentPropsFindings( stats.skipped += 1; } else { stats.dispatched += 1; + if (DATA_SOURCE_BOUND_ELEMENT_TYPES.has(type) && !namesBoundObject(value)) { + out.push( + ` · at ${joinKey(basePath, 'dataSource.object')}: \`${type}\` reads its records from the ` + + 'node-level `dataSource` binding only, and this node names no object there', + ); + } const parsed = schema.safeParse(props); if (!parsed.success) { const propsPath = joinKey(basePath, 'properties'); for (const issue of parsed.error.issues) { - if (suppliedByDataSource(issue, value)) continue; const at = issue.path.length === 0 ? propsPath : joinKey(propsPath, formatPath(issue.path)); out.push(` · at ${at}: ${issue.message}`); } @@ -1181,26 +1187,65 @@ function selfTest(): never { ); check('an unregistered SDUI block type is skipped, not refused', sdui.length === 0 && sduiStats.skipped === 1, sdui.join(' | ')); - // `dataSource.object` supplies the flat `object` prop — reporting it would - // be a wrong verdict, not a strict one (see DATASOURCE_SUPPLIED_PROP). + // The binding an element reads (#11509): the three data-source-bound + // elements owe `dataSource.object`, where this gate once WAIVED the flat + // `object` for any type whose binding named one. const boundStats = freshStats(); const bound = checkBlock( block( - 'type: element:record_picker\ndataSource:\n object: showcase_project\nproperties:\n limit: 50\n', + 'type: element:record_picker\ndataSource:\n object: showcase_project\n limit: 50\nproperties:\n labelField: name\n', 'PageComponentSchema', ), NAMESPACES, boundStats, ); - check('a component binding through `dataSource` is not refused for the flat `object` prop', + check('a data-source-bound element that names its object on the binding is not refused', bound.length === 0 && boundStats.dispatched === 1, bound.join(' | ')); const unbound = checkBlock( - block('type: element:record_picker\nproperties:\n limit: 50\n', 'PageComponentSchema'), + block('type: element:record_picker\nproperties:\n labelField: name\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('…while the same node with no binding is refused AT `dataSource.object`', + unbound.some((f) => f.includes('dataSource.object')), unbound.join(' | ')); + // The repeater trap, from the docs side: a repeater bound only through + // `dataSource` is the clean shape now, and a flat `object` is refused twice + // over — the tombstone at the key, and the missing binding. + const repeaterBound = checkBlock( + block('type: element:repeater\ndataSource:\n object: deal_note\nproperties:\n titleField: subject\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('a repeater bound only through `dataSource` is not refused', repeaterBound.length === 0, repeaterBound.join(' | ')); + const repeaterFlat = checkBlock( + block('type: element:repeater\nproperties:\n object: deal_note\n', 'PageComponentSchema'), + NAMESPACES, + freshStats(), + ); + check('a repeater aimed by a flat `object` is refused at the tombstone and at the missing binding', + repeaterFlat.some((f) => f.includes('properties.object') && f.includes('was removed')) + && repeaterFlat.some((f) => f.includes('dataSource.object')), + repeaterFlat.join(' | ')); + // Control: a type outside the set owes no binding. + const plain = checkBlock( + block('type: element:text\nproperties:\n content: Hi\n', 'PageComponentSchema'), NAMESPACES, freshStats(), ); - check('…while the same node with no binding at all still reports the missing prop', - unbound.some((f) => f.includes('properties.object')), unbound.join(' | ')); + check('an element outside the data-source-bound set owes no binding', plain.length === 0, plain.join(' | ')); + // The set is the spec's, not a recollection: every `ComponentPropsMap` row + // whose flat `object` is a tombstone pointing at `dataSource.object`. + const derived = Object.keys(COMPONENT_PROPS_SCHEMAS) + .filter((type) => { + const parsed = COMPONENT_PROPS_SCHEMAS[type]!.safeParse({ object: 'probe' }); + return !parsed.success && parsed.error.issues.some((i) => + i.path.length === 1 && i.path[0] === 'object' + && i.message.includes('was removed') && i.message.includes('`dataSource.object`')); + }) + .sort(); + check('the data-source-bound set equals the spec rows whose flat `object` is retired onto the binding', + JSON.stringify(derived) === JSON.stringify([...DATA_SOURCE_BOUND_ELEMENT_TYPES].sort()), + JSON.stringify(derived)); // Sequencing: the declared schema's verdict is never buried under a second one. const shortCircuit = checkBlock( diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index b20a36960dc..b819c5d0c60 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7302,10 +7302,12 @@ const elementFilterRemoved: MetadataConversion = { * The element layer's retired flat data-binding keys, per element type — the * keys {@link elementFlatDataBindingToDataSource} moves onto the node-level * `dataSource`. Declared here rather than imported from - * `ui/component.zod.ts` (its `RETIRED_ELEMENT_FLAT_BINDING_KEYS`), because this - * module is kept free of the page-component schemas it would drag into every - * bundle of the `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}); - * `element-flat-data-binding-to-data-source.test.ts` holds the two equal. + * `ui/component.zod.ts`, whose list is module-private (exporting it would widen + * a retirement that only narrows) and which this module is kept free of, so + * the page-component schemas are not dragged into every bundle of the + * `./shared` entry (see {@link CONVERSIONS_BY_MAJOR}). + * `element-flat-data-binding-to-data-source.test.ts` holds this list equal to + * the tombstones `ComponentPropsMap` carries. */ const ELEMENT_FLAT_BINDING_KEYS_BY_TYPE: Readonly> = { 'element:record_picker': ['object', 'filter', 'sort', 'limit'], diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 8bf719d97d7..e9e8c0546f2 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2602,20 +2602,22 @@ export const ElementTextPropsSchema = lazySchema(() => strictObject({ /** * The three elements whose flat data-binding keys retired in v18 (#11509, - * ruling A-narrow), and the keys each one carried. The single source for the - * tombstones below, the `element-flat-data-binding-to-data-source` conversion - * and the component-props gate's "no `dataSource.object`" refusal - * (`@objectstack/lint`), so the three cannot disagree about which element owes - * a binding. + * ruling A-narrow), and the keys each one carried — the source of the + * tombstones below. Module-private on purpose: exporting it would widen the + * published surface of a retirement that only narrows. The two readers that + * need the set — the `element-flat-data-binding-to-data-source` conversion and + * the component-props gate's missing-binding refusal (`@objectstack/lint`) — + * keep their own copy, and each copy is pinned against these tombstones by + * probing `ComponentPropsMap`, so none of the three can drift alone. */ -export const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { +const RETIRED_ELEMENT_FLAT_BINDING_KEYS = { 'element:record_picker': ['object', 'filter', 'sort', 'limit'], 'element:number': ['object', 'filter'], 'element:repeater': ['object', 'filter', 'sort', 'limit'], } as const satisfies Readonly>; /** An element whose query is the node-level `dataSource` binding only. */ -export type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; +type RetiredFlatBindingElementType = keyof typeof RETIRED_ELEMENT_FLAT_BINDING_KEYS; /** What the value of each retired key is — it moves unchanged. */ const ELEMENT_FLAT_BINDING_VALUE = { From fe970a9171f78e43b8509fc1426a8193e55df5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:46:10 +0000 Subject: [PATCH 04/14] wip(spec): retirement pins, absorbed narrowing pins, and the tests the retirement moves Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../test/my-work-visibility.test.ts | 12 +- .../src/protocol.stored-migration.test.ts | 12 +- ...ponent-filter-record-to-rule-array.test.ts | 74 +- ...omponent-action-element-rows-20371.test.ts | 18 +- ...nt-object-grid-default-filters.pin.test.ts | 148 ---- packages/spec/src/ui/component.test.ts | 442 +++-------- .../element-flat-binding-retirement.test.ts | 742 ++++++++++++++++++ .../src/ui/filter-rule-array-guidance.test.ts | 22 +- 8 files changed, 930 insertions(+), 540 deletions(-) delete mode 100644 packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts create mode 100644 packages/spec/src/ui/element-flat-binding-retirement.test.ts diff --git a/examples/app-showcase/test/my-work-visibility.test.ts b/examples/app-showcase/test/my-work-visibility.test.ts index 030c9c74f20..e7487df861d 100644 --- a/examples/app-showcase/test/my-work-visibility.test.ts +++ b/examples/app-showcase/test/my-work-visibility.test.ts @@ -77,10 +77,12 @@ const workQueueGrid = () => allComponents().find((c) => c.type === 'object-grid' * - **`filters` is DEAD.** Zero read points in the renderer on any ref, so it * is accepted at authoring time and dropped before the wire. That is the * #7750 defect itself. - * - **`defaultFilters` is ALIVE.** `ObjectGrid.tsx` reads it and lowers it to - * `params.$filter` — the legacy path `object-grid` still honors. It is - * pinned absent because this page authors the CURRENT key, NOT because the - * legacy one is inert. + * - **`defaultFilters` was ALIVE, and is RETIRED.** `ObjectGrid.tsx` read it + * and lowered it to `params.$filter` when `filter` lowered to nothing — the + * legacy second spelling of `filter`, which v18 retires (objectstack#11509): + * the spec refuses it with the prescription, and its conversion moves the + * rules onto an empty `filter`. It is pinned absent because this page + * authors the CURRENT key, NOT because the legacy one was inert. * * That distinction is the whole point of the card, so getting it wrong here * would reproduce the defect one level up: a future author debugging a filter @@ -94,7 +96,7 @@ const NON_CANONICAL_FILTER_SPELLINGS: ReadonlyArray<{ key: string; why: string } }, { key: 'defaultFilters', - why: 'is the LEGACY key `object-grid` still reads and lowers to `$filter` — it works, but it is not the key this page declares', + why: 'is the LEGACY second spelling of `filter`, read only when `filter` lowered to nothing and retired in v18 — not the key this page declares', }, ]; diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index c1f0aed627d..7b0cfa842aa 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -799,9 +799,12 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, grid({ stage: 'open' }), { type: 'object-kanban', dataSource: { object: 'deal', filter: COMBINATOR }, properties: { objectName: 'deal' } }, ]); - // The third door: `defaultFilters` on the grid, the same open bag. + // The third shape: the grid's `defaultFilters`, the same open bag. The key + // retired in v18 (#11509): with no `filter` beside it the fallback WAS the + // filter, so its retirement moves it onto `filter` — where the combinator + // is the record-form conversion's TODO, at the door it moved to. const defaultsMixed = pageRow('deal_grid', [ - { type: 'object-grid', properties: { objectName: 'deal', filter: { stage: 'open' }, defaultFilters: COMBINATOR } }, + { type: 'object-grid', properties: { objectName: 'deal', defaultFilters: COMBINATOR } }, ]); const { engine, tables } = makeStubEngine([mixedPage, bindingMixed, defaultsMixed]); const protocol = new ObjectStackProtocolImplementation(engine); @@ -816,9 +819,10 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, expect(binding.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].dataSource.filter']); const defaults = report.rows.find((r) => r.name === 'deal_grid')!; expect(defaults.outcome).toBe('rewritten'); - expect(defaults.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); + expect(defaults.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.filter']); const storedDefaults = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_grid')!.metadata); - expect(storedDefaults.regions[0].components[0].properties.defaultFilters).toEqual(COMBINATOR); + expect(storedDefaults.regions[0].components[0].properties.filter).toEqual(COMBINATOR); + expect(storedDefaults.regions[0].components[0].properties).not.toHaveProperty('defaultFilters'); // The rewritten row persisted its lossless half; the combinator is byte-identical. const stored = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_desk')!.metadata); diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index df24fe337de..07257fca72a 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -163,27 +163,60 @@ describe('§1 the ruled subset converts to the exact rule array', () => { expect(gridFilter({}).value).toEqual([]); }); - it('every door kind: the binding on any component, the block `filter`, the grid `defaultFilters`', () => { + it('every door kind: the binding on any component, and the block `filter`', () => { const { stack, notices } = convert( pageWith({ type: 'object-grid', dataSource: { object: 'deal', filter: { stage: 'open' } }, - properties: { objectName: 'deal', defaultFilters: { owner_id: 'u1' } }, + properties: { objectName: 'deal', filter: { owner_id: 'u1' } }, }), ); const component = componentOf(stack); expect((component.dataSource as Dict).filter).toEqual([ { field: 'stage', operator: 'equals', value: 'open' }, ]); - expect((component.properties as Dict).defaultFilters).toEqual([ + expect((component.properties as Dict).filter).toEqual([ { field: 'owner_id', operator: 'equals', value: 'u1' }, ]); expect(notices.map((n) => n.path)).toEqual([ 'pages[0].regions[0].components[0].dataSource.filter', - 'pages[0].regions[0].components[0].properties.defaultFilters', + 'pages[0].regions[0].components[0].properties.filter', ]); }); + /** + * The two doors that left this entry with #11509 (v18): `object-grid`'s + * `defaultFilters` and the elements' flat `filter`. Their retirements run + * BEFORE this entry and move a value onto the door it now lives at, so a + * record form there still reaches this entry — at the new door, converted + * exactly as any other filter there, each step with its own notice. + */ + it('a record form at a retired door reaches this entry at the door it moved to', () => { + const grid = convert(pageWith({ type: 'object-grid', properties: { objectName: 'deal', defaultFilters: { owner_id: 'u1' } } })); + expect(componentOf(grid.stack).properties).toEqual({ + objectName: 'deal', + filter: [{ field: 'owner_id', operator: 'equals', value: 'u1' }], + }); + expect(grid.notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['object-grid-default-filters-removed', 'pages[0].regions[0].components[0].properties.filter'], + [ID, 'pages[0].regions[0].components[0].properties.filter'], + ]); + + const element = convert(pageWith({ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: { stage: 'won' } } })); + expect(componentOf(element.stack)).toEqual({ + type: 'element:number', + properties: { aggregate: 'count' }, + dataSource: { object: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'won' }] }, + }); + expect(element.notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['element-flat-data-binding-to-data-source', 'pages[0].regions[0].components[0].dataSource.object'], + ['element-flat-data-binding-to-data-source', 'pages[0].regions[0].components[0].dataSource.filter'], + [ID, 'pages[0].regions[0].components[0].dataSource.filter'], + ]); + expect(grid.todos).toEqual([]); + expect(element.todos).toEqual([]); + }); + it('reaches slots and nested containers, as every page-component conversion does', () => { const { stack } = convert({ pages: [ @@ -253,19 +286,6 @@ describe('§1 the ruled subset converts to the exact rule array', () => { expect(todos).toEqual([]); }); - it('`defaultFilters` on an inline-row grid converts too', () => { - const { value, notices, todos } = (() => { - const { stack, notices: n, todos: t } = convert(pageWith({ - type: 'object-grid', - properties: { data: { provider: 'value', items: [] }, defaultFilters: { stage: 'open' } }, - })); - return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n, todos: t }; - })(); - expect(value).toEqual([{ field: 'stage', operator: 'equals', value: 'open' }]); - expect(notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); - expect(todos).toEqual([]); - }); - it('control: the same filter on an object-bound block of the same type converts', () => { for (const data of [undefined, { provider: 'object', object: 'deal' }]) { const { stack, notices, todos } = convert( @@ -373,7 +393,7 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => expect(todos).toEqual([]); }); - it('`defaultFilters` is converted on the grid only', () => { + it('`defaultFilters` on a block other than the grid is no door of this entry — nor of any', () => { const { stack, notices, todos } = convert( pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: { a: 1 } } }), ); @@ -526,14 +546,22 @@ describe('§6 the reach is the family, read off the schema', () => { const TYPES = Object.keys(ComponentPropsMap) as Array; - it.each(['filter', 'defaultFilters'])('`properties.%s`: converted exactly where the door refuses the record', (key) => { - const doors = TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], key)).sort(); - const reached = TYPES.filter((t) => converts(t, key)).sort(); + it('`properties.filter`: converted exactly where the door refuses the record', () => { + const doors = TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'filter')).sort(); + const reached = TYPES.filter((t) => converts(t, 'filter')).sort(); // Lit control: the schema walk really found the family. expect(doors.length).toBeGreaterThan(0); expect(reached).toEqual(doors); }); + it('`properties.defaultFilters` is a door of no block since v18 — retired, so this entry reaches it nowhere', () => { + // #11509 retired `object-grid.defaultFilters` (the one block that had it): + // the row answers every value with its removal prescription, never the + // rule-array one, and no block keeps a record form there after the chain. + expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters'))).toEqual([]); + expect(TYPES.filter((t) => converts(t, 'defaultFilters'))).toEqual([]); + }); + it('the binding door is on every component, so the binding converts on any type', () => { expect(refusesRecordWithPrescription(ElementDataSourceSchema, 'filter')).toBe(true); const { stack } = convert(pageWith({ type: 'page:card', dataSource: { object: 'deal', filter: { a: 1 } } })); @@ -754,9 +782,9 @@ describe('§8 the TODO channel — every site left as stored is reported (ruling const entry = ALL_CONVERSIONS.find((c) => c.id === ID)!; const { notices, todos } = convert(entry.fixture.before); expect(todos.map((t) => [t.path, t.reason.slice(0, 40)])).toEqual([ - ['pages[0].regions[0].components[1].properties.filter', 'On the `object-kanban` block, this filte'], + ['pages[0].regions[0].components[2].properties.filter', 'On the `object-kanban` block, this filte'], ]); - expect(notices.map((n) => n.path)).toContain('pages[0].regions[0].components[2].properties.filter'); + expect(notices.map((n) => n.path)).toContain('pages[0].regions[0].components[3].properties.filter'); }); it('reporting writes nothing: every decline yields the same stack with or without a sink', () => { diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index 833a5752428..3b0810242b3 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -120,6 +120,9 @@ describe('key sets, asserted whole — measured from the renderers\' read points }); it('element:repeater', () => { + // `object` / `filter` / `sort` / `limit` are tombstones since v18 (#11509): + // a `retiredKey()` stays a key of the walked shape, and the list's query is + // the node-level `dataSource` binding. expect(keysOf(ElementRepeaterPropsSchema)).toEqual( ['object', 'titleField', 'fields', 'filter', 'sort', 'limit', 'emptyText', 'divided'].sort(), ); @@ -174,15 +177,16 @@ describe('one accepted authored example per type, taken from objectui', () => { }); it('element:repeater — objectui\'s own renderer specimen', () => { - const authored = { object: 'showcase_category', fields: ['name'], emptyText: 'Nothing here' }; + // The specimen's `object` moved onto the node-level binding in v18 + // (#11509); the props bag keeps the display keys. + const authored = { fields: ['name'], emptyText: 'Nothing here' }; expect(ElementRepeaterPropsSchema.parse(authored)).toEqual(authored); }); }); describe('strict from birth — an unknown key is refused on every row', () => { it.each(Object.entries(ROWS))('`%s` refuses an undeclared key, naming its surface', (type, schema) => { - const base = type === 'element:repeater' ? { object: 'task' } : {}; - const issue = unknownKeyIssue(schema.safeParse({ ...base, notARealProp: 1 })); + const issue = unknownKeyIssue(schema.safeParse({ notARealProp: 1 })); expect(issue.keys).toEqual(['notARealProp']); expect(issue.message).toContain(`\`${type}\``); }); @@ -322,10 +326,12 @@ describe('what the measurement decided, pinned', () => { } }); - it('repeater: the `object-*` family\'s `objectName` is refused and renamed to `object`', () => { - const issue = unknownKeyIssue(ElementRepeaterPropsSchema.safeParse({ object: 'task', objectName: 'task' })); + it('repeater: the `object-*` family\'s `objectName` is refused and pointed at the binding\'s `object`', () => { + // Until v18 this renamed to the flat `object`; that key retired onto the + // node-level binding (#11509), so the spelling points there instead. + const issue = unknownKeyIssue(ElementRepeaterPropsSchema.safeParse({ objectName: 'task' })); expect(issue.keys).toEqual(['objectName']); - expect(issue.message).toContain('`object`'); + expect(issue.message).toContain('`dataSource.object`'); }); }); diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts deleted file mode 100644 index e4b8da529c7..00000000000 --- a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#19514] `object-grid`'s `defaultFilters` carries the SAME declaration as its - * `filter` sibling. - * - * The key is described as "read only when `filter` is absent" — the same value - * in the same role — and objectui's `ObjectGrid` reads it through the same - * lowering sink. `filter` converged on the `ViewFilterRule` array with the rest - * of its family; this key was not named by that ruling and kept the - * pre-convergence `z.unknown()`, so the block had one declared door and one - * undeclared door onto one seam: a bare string, a number, a MongoDB-style - * record, an ObjectQL AST tuple array and a list of malformed rules all parsed - * here. At the objectui `.objectui-sha` pin `87af769e9a` the lowering - * treats them three ways, per shape: the record form and the tuple array are - * lowered and APPLIED as declared; a bare string or a number is DROPPED, so the - * grid sends no filter and lists its rows unfiltered; and a list of malformed - * rules is REFUSED, on the wire or by the client before any request. - * - * These pins hold the two keys EQUAL rather than transcribing a list of shapes - * — the equality is the rule, and a list would go stale the next time `filter` - * moves. Both directions are pinned at each key: the newly-refused shapes - * REFUSE and the shape that must keep working ACCEPTS, because an ACCEPT-only - * suite passes just as well against a door that has been narrowed to refuse - * everything. - * - * ⛔ Narrowed, NOT retired. Refusing the key outright is a REMOVAL of an - * accepted shape and needs its own ruling; the deprecation already stated in - * the description is unchanged. The pin below says so in the one way a test - * can: a well-formed `defaultFilters` still parses. - */ - -import { describe, expect, it } from 'vitest'; - -import { ComponentPropsMap } from './component.zod'; - -const GRID = ComponentPropsMap['object-grid']; - -/** A valid grid node with one key under test swapped in. */ -const node = (extra: Record) => ({ objectName: 'account', ...extra }); - -/** The rule array both keys take. */ -const RULES = [{ field: 'status', operator: 'equals', value: 'active' }] as const; - -/** The shapes the `z.unknown()` door used to receipt as valid. */ -const REFUSED_SHAPES: readonly (readonly [string, unknown])[] = [ - ['a bare string', 'status = active'], - ['a number', 42], - ['a boolean', true], - ['the MongoDB-style record form', { status: 'active' }], - ['an operator-object record form', { amount: { $gt: 100 } }], - ['an ObjectQL AST tuple array', [['owner_id', '=', '{current_user_id}']]], - ['an array of malformed rules', [{ nonsense: true }]], -]; - -describe('defaultFilters refuses what filter refuses', () => { - it.each(REFUSED_SHAPES.map(([label, value]) => [label, value] as const))( - 'refuses %s at the defaultFilters path', - (_label, value) => { - const result = GRID.safeParse(node({ defaultFilters: value })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const under = result.error.issues.filter((i) => String(i.path[0]) === 'defaultFilters'); - expect(under.length).toBeGreaterThan(0); - }, - ); - - it('the two keys agree shape for shape — the rule, not a transcribed list', () => { - // If `filter` is narrowed or widened again, this is what holds the fallback - // to it. A list of literals here would silently stop tracking. - for (const [label, value] of REFUSED_SHAPES) { - const onFilter = GRID.safeParse(node({ filter: value })).success; - const onFallback = GRID.safeParse(node({ defaultFilters: value })).success; - expect(onFallback, `${label}: defaultFilters`).toBe(onFilter); - } - expect(GRID.safeParse(node({ filter: RULES })).success).toBe(true); - expect(GRID.safeParse(node({ defaultFilters: RULES })).success).toBe(true); - }); - - it('the record form gets the conversion table, naming the key that was written', () => { - const result = GRID.safeParse(node({ defaultFilters: { status: 'active' } })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const issue = result.error.issues.find((i) => i.path.join('.') === 'defaultFilters')!; - expect(issue.message).toContain('`defaultFilters`'); - expect(issue.message).toContain('[{ field, operator, value }, ...]'); - // The rewrite is computed from the author's own keys, as at every sibling door. - expect(issue.message).toContain("[{ field: 'status', operator: 'equals', value: 'active' }]"); - expect(issue.message).toContain('migration `object-grid-default-filters-rule-array`'); - }); - - it.each([ - ['the rule array', RULES], - ['an empty rule array', []], - ['a multi-rule array', [ - { field: 'status', operator: 'equals', value: 'active' }, - { field: 'stage', operator: 'in', value: ['won', 'lost'] }, - ]], - ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { - expect(GRID.safeParse(node({ defaultFilters: value })).success).toBe(true); - }); - - it('NEGATIVE CONTROL — absence is still absence, and the key is still optional', () => { - const result = GRID.safeParse(node({})); - expect(result.success).toBe(true); - if (!result.success) throw new Error('unreachable'); - expect('defaultFilters' in (result.data as Record)).toBe(false); - }); - - it('NEGATIVE CONTROL — both keys together still parse, as the fallback contract allows', () => { - // The key is read only when `filter` is absent; authoring both has never - // been an error and this narrowing does not make it one. - expect(GRID.safeParse(node({ filter: RULES, defaultFilters: RULES })).success).toBe(true); - }); - - it('ENVELOPE CONTROL — the door refuses an unrelated thing, so it is reachable', () => { - // The card's own discriminating control. If this reads ACCEPT, the block is - // not being parsed at all and every REFUSE above is a phantom. - const result = GRID.safeParse({ objectName: 42 }); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - expect(result.error.issues.some((i) => i.path.join('.') === 'objectName')).toBe(true); - }); - - it('the element-level issues of an array author are still their own', () => { - // The fall-through `ruleArrayFilterError` protects: a blanket message at the - // key would overwrite the diagnosis an array author actually needs. - const result = GRID.safeParse(node({ defaultFilters: [{ field: 'status', operator: 'nope', value: 'a' }] })); - expect(result.success).toBe(false); - if (result.success) throw new Error('unreachable'); - const under = result.error.issues.filter((i) => i.path.join('.').startsWith('defaultFilters.')); - expect(under.length).toBeGreaterThan(0); - for (const issue of under) expect(issue.message).not.toContain('migration `'); - expect(result.error.issues.filter((i) => i.path.join('.') === 'defaultFilters')).toHaveLength(0); - }); - - it('the rule-level narrowings reach THIS key too — one schema, every carrier', () => { - // The scalar arm and the icontains comparand door are `ViewFilterRuleSchema`'s, - // so they arrive here by construction. Pinned because "the fallback is the - // same declaration" is the whole claim of this file. - expect(GRID.safeParse(node({ - defaultFilters: [{ field: 'tags', operator: 'equals', value: ['a'] }], - })).success).toBe(false); - expect(GRID.safeParse(node({ - defaultFilters: [{ field: 'name', operator: 'icontains', value: '' }], - })).success).toBe(false); - }); -}); diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 776b6d64bb5..5749466fe6a 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -1692,7 +1692,8 @@ describe('Content Elements', () => { it('should accept element:number component', () => { expect(() => PageComponentSchema.parse({ type: 'element:number', - properties: { object: 'order', aggregate: 'count' }, + dataSource: { object: 'order' }, + properties: { aggregate: 'count' }, })).not.toThrow(); }); @@ -1711,90 +1712,10 @@ describe('Content Elements', () => { }); }); -// --------------------------------------------------------------------------- -// element:number `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B) -// --------------------------------------------------------------------------- -describe("element:number `filter` — one filter orthography platform-wide", () => { - const number = ComponentPropsMap['element:number']; - const relatedList = ComponentPropsMap['record:related_list']; - const RULES = [{ field: 'status', operator: 'equals', value: 'won' }]; - const RECORD_FORM = { status: 'won' }; - /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ - const issuesAt = (r: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }, key: string) => - r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); - - it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { - // Before the 2026-08-25 ruling this exact value was REFUSED here (the entry - // said `FilterConditionSchema`, the MongoDB-style record) while every - // sibling `filter` input in the map accepted it. - const r = number.safeParse({ object: 'order', aggregate: 'count', filter: RULES }); - expect(r.success).toBe(true); - expect(r.data!.filter).toEqual(RULES); - }); - - it('the array carries the REAL ViewFilterRuleSchema, not a lookalike: operators normalize, value shapes are checked', () => { - // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a - // plain `z.array(z.object(...))` would have echoed it back unchanged. - const legacy = number.safeParse({ - object: 'order', aggregate: 'count', - filter: [{ field: 'status', operator: 'eq', value: 'won' }], - }); - expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('equals'); - // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's - // own superRefine — the value-shape check rides in with the schema. - const scalarIn = number.safeParse({ - object: 'order', aggregate: 'count', - filter: [{ field: 'status', operator: 'in', value: 'won' }], - }); - expect(scalarIn.success).toBe(false); - expect(scalarIn.error!.issues.map((i) => i.path.join('.'))).toContain('filter.0.value'); - }); - - it('the MongoDB-style record form — what this entry alone used to accept — is REFUSED at the `filter` path', () => { - // Reverse verification of the convergence, asserted on the issue envelope - // rather than on a bare `success === false`: the refusal is located at - // `filter` and names the expected kind. Migration: - // `element-number-filter-rule-array`. - const r = number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }); - expect(r.success).toBe(false); - const atFilter = issuesAt(r, 'filter'); - expect(atFilter).toHaveLength(1); - expect(atFilter[0].code).toBe('invalid_type'); - expect(atFilter[0]).toMatchObject({ expected: 'array' }); - // An operator-object record (`{ amount: { $gt: 100 } }`) is the same form - // and gets the same verdict — no arm accepts any spelling of the record. - const opRecord = number.safeParse({ object: 'order', aggregate: 'sum', field: 'amount', filter: { amount: { $gt: 100 } } }); - expect(opRecord.success).toBe(false); - expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - }); - - it('shares the array orthography with the sibling `filter` inputs — one value, two doors, the same verdicts', () => { - // The ruling is "one filter orthography platform-wide", so the pin is - // cross-entry: the same rule array raises no issue at `filter` on either - // door, and the same record form is refused at `filter` with the same - // issue code on both. Each door is asked only about ITS `filter` — the - // other keys the related list requires are not this pin's subject. - expect(issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); - const numberRefusal = issuesAt(number.safeParse({ object: 'order', aggregate: 'count', filter: RECORD_FORM }), 'filter'); - const relatedRefusal = issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter'); - expect(numberRefusal.map((i) => i.code)).toEqual(['invalid_type']); - expect(relatedRefusal.map((i) => i.code)).toEqual(numberRefusal.map((i) => i.code)); - }); - - it('positive control: a well-formed multi-rule array with a real `in` rule parses through the element', () => { - const r = number.safeParse({ - object: 'order', aggregate: 'sum', field: 'amount', - filter: [ - { field: 'status', operator: 'in', value: ['won', 'closed'] }, - { field: 'amount', operator: 'greater_than', value: 100 }, - ], - }); - expect(r.success).toBe(true); - expect(r.data!.filter).toHaveLength(2); - }); -}); +// `element:number`'s flat `filter` — the ViewFilterRule-array door ui#6206-B +// converged — retired in v18 with its flat `object` (#11509): the element's +// filter is `dataSource.filter`, the binding's own rule-array door. The +// retirement is pinned in `element-flat-binding-retirement.test.ts`. // --------------------------------------------------------------------------- // Element Props Schemas @@ -1911,28 +1832,23 @@ describe('ElementTextPropsSchema', () => { describe('ElementNumberPropsSchema', () => { it('should accept minimal number props', () => { - const props = ElementNumberPropsSchema.parse({ - object: 'order', - aggregate: 'count', - }); - expect(props.object).toBe('order'); + // The object is the node-level `dataSource.object` since v18 (#11509); + // the props bag carries the aggregate alone. + const props = ElementNumberPropsSchema.parse({ aggregate: 'count' }); expect(props.aggregate).toBe('count'); expect(props.field).toBeUndefined(); }); it('should accept full number props', () => { + // `object` / `filter` are the binding's since v18 (#11509). const props = ElementNumberPropsSchema.parse({ - object: 'order', field: 'amount', aggregate: 'sum', - // The ViewFilterRule array form (ui#6206-B) — this fixture authored the - // record form `{ status: 'paid' }` while the entry alone accepted it. - filter: [{ field: 'status', operator: 'equals', value: 'paid' }], format: 'currency', prefix: '$', suffix: ' USD', }); - expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'paid' }]); + expect(props.field).toBe('amount'); expect(props.format).toBe('currency'); expect(props.prefix).toBe('$'); expect(props.suffix).toBe(' USD'); @@ -1941,20 +1857,20 @@ describe('ElementNumberPropsSchema', () => { it('should accept all aggregate functions', () => { const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const; aggregates.forEach(aggregate => { - expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate })).not.toThrow(); + expect(() => ElementNumberPropsSchema.parse({ aggregate })).not.toThrow(); }); }); it('should accept all format options', () => { const formats = ['number', 'currency', 'percent'] as const; formats.forEach(format => { - expect(() => ElementNumberPropsSchema.parse({ object: 'order', aggregate: 'count', format })).not.toThrow(); + expect(() => ElementNumberPropsSchema.parse({ aggregate: 'count', format })).not.toThrow(); }); }); it('should reject without required fields', () => { expect(() => ElementNumberPropsSchema.parse({})).toThrow(); - expect(() => ElementNumberPropsSchema.parse({ object: 'order' })).toThrow(); + expect(() => ElementNumberPropsSchema.parse({ field: 'amount' })).toThrow(); }); }); @@ -2015,11 +1931,8 @@ describe('ComponentPropsMap content elements', () => { }); it('should parse element:number props', () => { - const result = ComponentPropsMap['element:number'].parse({ - object: 'order', - aggregate: 'count', - }); - expect(result.object).toBe('order'); + const result = ComponentPropsMap['element:number'].parse({ aggregate: 'count' }); + expect(result.aggregate).toBe('count'); }); it('should parse element:image props', () => { @@ -2326,54 +2239,43 @@ describe('element:filter / element:form are refused by name at the node', () => // Interactive Elements — element:record_picker // --------------------------------------------------------------------------- describe('Interactive Elements — element:record_picker', () => { + // Since v18 (#11509) the picker's query — object, view, filter, sort, limit — + // is the node-level `dataSource` binding, and the props bag carries display + // config only. The four flat binding keys' retirement is pinned in + // `element-flat-binding-retirement.test.ts`. it('should accept element:record_picker component', () => { expect(() => PageComponentSchema.parse({ type: 'element:record_picker', - properties: { object: 'account', labelField: 'name' }, + dataSource: { object: 'account' }, + properties: { labelField: 'name' }, })).not.toThrow(); }); it('should parse record_picker props with defaults', () => { - const props = ElementRecordPickerPropsSchema.parse({ - object: 'account', - labelField: 'name', - }); - expect(props.object).toBe('account'); + const props = ElementRecordPickerPropsSchema.parse({ labelField: 'name' }); expect(props.labelField).toBe('name'); }); it('should accept full record_picker props', () => { const props = ElementRecordPickerPropsSchema.parse({ - object: 'account', labelField: 'name', valueField: 'id', label: 'Account', - // The ViewFilterRule array form (ui#6206-B, #14406) — this fixture - // authored the record form `{ status: 'active' }` while the entry alone - // accepted it. - filter: [{ field: 'status', operator: 'equals', value: 'active' }], placeholder: 'Search accounts...', emptyText: 'No accounts', }); expect(props.labelField).toBe('name'); expect(props.valueField).toBe('id'); expect(props.label).toBe('Account'); - expect(props.filter).toEqual([{ field: 'status', operator: 'equals', value: 'active' }]); expect(props.emptyText).toBe('No accounts'); }); - it('should reject record_picker without its one required field', () => { - expect(() => ElementRecordPickerPropsSchema.parse({})).toThrow(); - }); - - // #5775 — `object` is the ONLY required prop. `labelField` is optional - // because the renderer defaults it to `name` (`props.labelField ?? 'name'`), - // so omitting it is a working picker, not a broken one. This is the half of - // the ruling that lets the showcase's `page-variables` page stop reporting - // `component-props-invalid` (a required key it had no reason to write). - it('accepts a picker with `object` alone — labelField defaults in the renderer', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'account' }); - expect(props.object).toBe('account'); + // #5775 made `object` the ONLY required prop (`labelField` defaults to + // `name` in the renderer, so omitting it is a working picker). #11509 moved + // that `object` onto the binding, so an empty bag is a complete one: the + // requirement is the component-props gate's, at `dataSource.object`. + it('accepts an empty props bag — the object is the binding\'s, labelField defaults in the renderer', () => { + const props = ElementRecordPickerPropsSchema.parse({}); expect(props.labelField).toBeUndefined(); }); @@ -2382,222 +2284,114 @@ describe('Interactive Elements — element:record_picker', () => { label: 'Project', labelField: 'name', placeholder: 'Choose a project…', - object: 'showcase_project', }); expect(props.labelField).toBe('name'); expect(props.label).toBe('Project'); + // …and the node it sits on, binding included. + expect(PageComponentSchema.safeParse({ + type: 'element:record_picker', + id: 'project_picker', + dataSource: { object: 'showcase_project', limit: 50 }, + properties: { label: 'Project', labelField: 'name', placeholder: 'Choose a project…' }, + }).success).toBe(true); }); // #5775 tombstones — the prescription IS the payload. `displayField` was a // REQUIRED declaration no renderer read; `searchFields` / `multiple` were // capability claims the single-select control never kept (ADR-0049). it('rejects the retired `displayField` with the rename prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', displayField: 'title' })) + expect(() => ElementRecordPickerPropsSchema.parse({ displayField: 'title' })) .toThrow(/displayField.*removed.*use `labelField`|displayField.*removed.*`labelField`/s); }); it('rejects the retired `searchFields` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', searchFields: ['name'] })) + expect(() => ElementRecordPickerPropsSchema.parse({ searchFields: ['name'] })) .toThrow(/`searchFields`.*removed.*Delete the key/s); }); + it('the `searchFields` prescription names the binding\'s filter, never the retired flat one', () => { + const r = ElementRecordPickerPropsSchema.safeParse({ searchFields: ['name'] }); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(message).toContain('use the component-level `dataSource.filter`'); + expect(message).not.toContain('use `filter`'); + }); + it('rejects the retired `multiple` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', multiple: true })) + expect(() => ElementRecordPickerPropsSchema.parse({ multiple: true })) .toThrow(/`multiple`.*removed.*Delete the key/s); }); it('does not materialize the retired keys on a clean parse', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'a' }); - expect(props).not.toHaveProperty('displayField'); - expect(props).not.toHaveProperty('searchFields'); - expect(props).not.toHaveProperty('multiple'); - expect(props).not.toHaveProperty('targetVariable'); + const props = ElementRecordPickerPropsSchema.parse({}); + for (const key of ['displayField', 'searchFields', 'multiple', 'targetVariable', 'object', 'filter', 'sort', 'limit']) { + expect(props).not.toHaveProperty(key); + } }); // #9198 tombstone — `targetVariable` was a declarative hint with zero // readers; the live binding is the page variable whose `source` names this // component's `id` (ADR-0049 enforce-or-remove). it('rejects the retired `targetVariable` with its prescription', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', targetVariable: 'selected_id' })) + expect(() => ElementRecordPickerPropsSchema.parse({ targetVariable: 'selected_id' })) .toThrow(/`targetVariable`.*removed.*Delete the key/s); }); - // ── commit 78f0be872 — the flat `sort` / `limit` shorthands ────────────── - // The renderer resolves four keys through one pattern - // (`ds. ?? props.`); after #5775 two of the four flat spellings were - // declared and two were not. These pin the other two, in BOTH halves of what - // a declaration buys: the key is retained (not stripped into silence) and the - // VALUE is judged (a wrong shape is rejected by name rather than dropped). - it('retains the flat `sort` shorthand — declared, not stripped', () => { - const props = ElementRecordPickerPropsSchema.parse({ - object: 'showcase_project', - sort: [{ field: 'created_at', order: 'desc' }], - }); - expect(props.sort).toEqual([{ field: 'created_at', order: 'desc' }]); - }); - - it('retains the flat `limit` shorthand — declared, not stripped', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'showcase_project', limit: 20 }); - expect(props.limit).toBe(20); - }); - - // The exact ADR-0078 trap the issue reported: an author who infers - // `properties.limit: 20` from the declared `object`/`filter` spelling used to - // get the renderer's default 50 with zero diagnostics, because the key was - // stripped before anything could read it. - it('rejects a non-integer / non-positive `limit` by name', () => { - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 0 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: -5 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 2.5 })).toThrow(/limit/); - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', limit: 'ten' })).toThrow(/limit/); - }); - - it('rejects a malformed `sort` by name', () => { - // A bare field name — the shape an author reaches for when the key is - // undeclared and nothing has ever told them otherwise. - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', sort: 'created_at' })) - .toThrow(/sort/); - // Right container, wrong direction vocabulary. - expect(() => ElementRecordPickerPropsSchema.parse({ - object: 'a', - sort: [{ field: 'created_at', order: 'descending' }], - })).toThrow(/sort/); - // Right container, missing the required half of the pair. - expect(() => ElementRecordPickerPropsSchema.parse({ object: 'a', sort: [{ field: 'created_at' }] })) + // ── the query's shapes, at the one door that carries them ──────────────── + // Commit 78f0be872 declared the flat `sort` / `limit` in the binding's own + // shapes so the two spellings could not drift into a third dialect; #11509 + // retired the flat spelling, and these shapes are now the binding's alone. + // The renderer's `?? 50` stays a renderer fallback, never a schema default. + it('the binding judges `sort` / `limit` by name, and does not default `limit`', () => { + for (const limit of [0, -5, 2.5, 'ten']) { + expect(() => ElementDataSourceSchema.parse({ object: 'a', limit })).toThrow(/limit/); + } + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: 'created_at' })).toThrow(/sort/); + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: [{ field: 'created_at', order: 'descending' }] })) .toThrow(/sort/); - }); - - // The shorthand IS the `dataSource` key, so one value must parse identically - // through both doors. This is what stops the flat spelling drifting into a - // third sort dialect (the ledger's `report.zod.ts` row records three already). - it('parses `sort` / `limit` identically to `dataSource` (one shape, two spellings)', () => { - const sort = [{ field: 'name', order: 'asc' as const }]; - const viaProps = ElementRecordPickerPropsSchema.parse({ object: 'a', sort, limit: 25 }); - const viaDataSource = ElementDataSourceSchema.parse({ object: 'a', sort, limit: 25 }); - expect(viaProps.sort).toEqual(viaDataSource.sort); - expect(viaProps.limit).toEqual(viaDataSource.limit); - // …and the same rejections on the same values. - expect(ElementRecordPickerPropsSchema.safeParse({ object: 'a', limit: 0 }).success) - .toBe(ElementDataSourceSchema.safeParse({ object: 'a', limit: 0 }).success); - expect(ElementRecordPickerPropsSchema.safeParse({ object: 'a', sort: 'name' }).success) - .toBe(ElementDataSourceSchema.safeParse({ object: 'a', sort: 'name' }).success); - }); - - // The renderer's `?? 50` is a RENDERER fallback, deliberately not a schema - // default: `.default(50)` would materialize a limit on every parsed picker - // and turn an unset key into an authored one (and would then have to be kept - // in sync with objectui by hand). - it('does not default `limit` — the 50 is the renderer fallback', () => { - const props = ElementRecordPickerPropsSchema.parse({ object: 'a' }); - expect(props.limit).toBeUndefined(); - expect(props.sort).toBeUndefined(); + expect(() => ElementDataSourceSchema.parse({ object: 'a', sort: [{ field: 'created_at' }] })).toThrow(/sort/); + const parsed = ElementDataSourceSchema.parse({ object: 'a' }); + expect(parsed.limit).toBeUndefined(); + expect(parsed.sort).toBeUndefined(); }); }); // --------------------------------------------------------------------------- -// element:record_picker `filter` — the ViewFilterRule ARRAY orthography (ui#6206-B, #14406) +// The `filter` doors of ComponentPropsMap — the census of the one orthography +// (ui#6206-B, #14406). `element:record_picker`'s and `element:number`'s flat +// `filter` were doors of it until #11509 retired them in v18 onto the binding's +// own rule-array door, `dataSource.filter`; a retired door is a tombstone that +// refuses EVERY value, so the census below asks the live doors only. // --------------------------------------------------------------------------- -describe("element:record_picker `filter` — one filter orthography platform-wide", () => { - const picker = ComponentPropsMap['element:record_picker']; - const number = ComponentPropsMap['element:number']; - const relatedList = ComponentPropsMap['record:related_list']; +describe('the live `filter` doors of ComponentPropsMap — one filter orthography platform-wide', () => { const RULES = [{ field: 'status', operator: 'equals', value: 'active' }]; - const RECORD_FORM = { status: 'active' }; - type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + type ParseResult = { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string; message: string }> } }; + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; /** The issues a parse raised AT `key` (top-level), whatever else it raised. */ const issuesAt = (r: ParseResult, key: string) => r.success ? [] : r.error!.issues.filter((i) => i.path[0] === key); + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door; + /** A retired `filter` refuses the rule array with its removal prescription. */ + const isRetired = (type: string) => + issuesAt(door(type).safeParse({ filter: RULES }), 'filter').some((i) => i.message.includes('was removed')); - it('accepts a ViewFilterRule[] filter — the acceptance criterion', () => { - // Before #14406 this exact value was REFUSED here — the entry said - // `FilterConditionSchema`, the MongoDB-style record, the LAST one in the - // map — while every sibling `filter` input accepted it. Measured at the - // objectui pin before the declaration moved: the renderer hands the value - // to `query.$filter`, and `adapter.find()` lowers a rule array through - // `translateFilterArray`, so the array reaches the query. - const r = picker.safeParse({ object: 'account', filter: RULES }); - expect(r.success).toBe(true); - expect(r.data!.filter).toEqual(RULES); - }); - - it('the array carries the REAL ViewFilterRuleSchema, not a lookalike: operators normalize, value shapes are checked', () => { - // `eq` is a legacy spelling `normalizeFilterOperator` lowers to `equals` — a - // plain `z.array(z.object(...))` would have echoed it back unchanged. - const legacy = picker.safeParse({ - object: 'account', - filter: [{ field: 'status', operator: 'eq', value: 'active' }], - }); - expect(legacy.success).toBe(true); - expect(legacy.data!.filter![0].operator).toBe('equals'); - // `in` takes an array; a scalar is refused at `filter.0.value` by the rule's - // own superRefine — the value-shape check rides in with the schema. - const scalarIn = picker.safeParse({ - object: 'account', - filter: [{ field: 'status', operator: 'in', value: 'active' }], - }); - expect(scalarIn.success).toBe(false); - expect(scalarIn.error!.issues.map((i) => i.path.join('.'))).toContain('filter.0.value'); - }); - - it('the MongoDB-style record form — what this entry alone used to accept — is REFUSED at the `filter` path', () => { - // Reverse verification of the convergence, asserted on the issue envelope - // rather than on a bare `success === false`: the refusal is located at - // `filter` and names the expected kind. Migration: - // `element-record-picker-filter-rule-array`. - const r = picker.safeParse({ object: 'account', filter: RECORD_FORM }); - expect(r.success).toBe(false); - const atFilter = issuesAt(r, 'filter'); - expect(atFilter).toHaveLength(1); - expect(atFilter[0].code).toBe('invalid_type'); - expect(atFilter[0]).toMatchObject({ expected: 'array' }); - // An operator-object record and a `$and` group are the same form and get - // the same verdict — no arm accepts any spelling of the record. - const opRecord = picker.safeParse({ object: 'account', filter: { amount: { $gt: 100 } } }); - expect(issuesAt(opRecord, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - const group = picker.safeParse({ object: 'account', filter: { $and: [{ status: 'active' }] } }); - expect(issuesAt(group, 'filter').map((i) => i.code)).toEqual(['invalid_type']); - }); - - it('shares the array orthography with the sibling `filter` inputs — one value, three doors, the same verdicts', () => { - // The ruling is "one filter orthography platform-wide" and this entry was - // the last holdout, so the pin is cross-entry: the same rule array raises - // no issue at `filter` on any of the three declared doors, and the same - // record form is refused at `filter` with the same issue code on all - // three. Each door is asked only about ITS `filter`. - expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(relatedList.safeParse({ filter: RULES }), 'filter')).toEqual([]); - const pickerRefusal = issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code); - expect(pickerRefusal).toEqual(['invalid_type']); - expect(issuesAt(number.safeParse({ object: 'account', aggregate: 'count', filter: RECORD_FORM }), 'filter').map((i) => i.code)) - .toEqual(pickerRefusal); - expect(issuesAt(relatedList.safeParse({ filter: RECORD_FORM }), 'filter').map((i) => i.code)).toEqual(pickerRefusal); - }); - - it('no top-level `filter` door in ComponentPropsMap refuses the rule array any more — the census the card closes', () => { + it('no live top-level `filter` door refuses the rule array any more — the census the card closes', () => { // The card's claim is "the last record-form `filter` in `ComponentPropsMap`". - // Asserted over the WHOLE map by shape rather than over the entries named - // above, so a future entry declaring `FilterConditionSchema` at `filter` - // (which refuses an array outright, `invalid_type`) is caught here by - // name. The holdout shape is exactly "declares `filter`, refuses the - // array". A door declaring `z.unknown()` accepted both forms and was never - // a holdout of THIS census by construction — which is why the four - // `object-*` doors needed the complementary pin below (#15449): "every - // `filter` door refuses the record". - type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; + // Asserted over the WHOLE map by shape rather than over named entries, so a + // future entry declaring `FilterConditionSchema` at `filter` (which refuses + // an array outright, `invalid_type`) is caught here by name. const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) .filter(([, schema]) => { const shape = (schema as Door).shape; return !!shape && 'filter' in shape; }) .map(([type]) => type); - // Guard the probe: the three doors pinned above must be found, or the - // shape read has gone wrong and the loop below is vacuous. - expect(doors).toEqual(expect.arrayContaining(['element:record_picker', 'element:number', 'record:related_list'])); - const holdouts = doors.filter((type) => { - const r = (ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door).safeParse({ filter: RULES }); - return issuesAt(r, 'filter').length > 0; - }); + // Guard the probe: the retired doors are still keys of their shapes (a + // tombstone is a key), and they are exactly the three element rows. + const retired = doors.filter(isRetired).sort(); + expect(retired).toEqual(['element:number', 'element:record_picker', 'element:repeater']); + const live = doors.filter((type) => !isRetired(type)); + expect(live).toEqual(expect.arrayContaining(['record:related_list', 'object-grid'])); + const holdouts = live.filter((type) => issuesAt(door(type).safeParse({ filter: RULES }), 'filter').length > 0); expect(holdouts).toEqual([]); }); }); @@ -2767,15 +2561,15 @@ describe('the four `object-*` `sort` doors — one sort orthography, the array', expect(unrecognized.flatMap((i) => i.keys ?? [])).toContain('bogusProp'); }); - it('`sort` agrees with `dataSource.sort` and with the picker shorthand — one shape, four doors', () => { + it('`sort` agrees with `dataSource.sort` — one shape, the four doors and the binding', () => { // The map's own copies are the same import (`SortItemSchema`), so this // asks the question the copies could not: do the doors AGREE, value for - // value, with the binding every data-bound element already carries. + // value, with the binding every data-bound element already carries. (The + // record picker's flat `sort` was a fifth door until #11509 retired it in + // v18 onto that binding.) const viaBinding = ElementDataSourceSchema.parse({ object: 'showcase_task', sort: ARRAY_FORM }); - for (const type of [...SORT_DOORS, 'element:record_picker']) { - const value = type === 'element:record_picker' - ? { object: 'showcase_task', sort: ARRAY_FORM } - : { objectName: 'showcase_task', sort: ARRAY_FORM }; + for (const type of SORT_DOORS) { + const value = { objectName: 'showcase_task', sort: ARRAY_FORM }; const r = door(type).safeParse(value); expect([type, r.success]).toEqual([type, true]); expect([type, r.data!.sort]).toEqual([type, viaBinding.sort]); @@ -2918,11 +2712,8 @@ describe('ComponentPropsMap interactive elements', () => { }); it('should parse element:record_picker props', () => { - const result = ComponentPropsMap['element:record_picker'].parse({ - object: 'account', - labelField: 'name', - }); - expect(result.object).toBe('account'); + const result = ComponentPropsMap['element:record_picker'].parse({ labelField: 'name' }); + expect(result.labelField).toBe('name'); }); it('should parse element:text_input props', () => { @@ -3499,41 +3290,16 @@ describe('object-* block props schemas — declared, so the props gate has a sch expect(r.error!.issues.some((i) => i.path[0] === 'data')).toBe(true); }); - it('`defaultFilters` stays HONOURED — it is a read legacy fallback, not an inert spelling', () => { - // ObjectGrid.tsx reads it and lowers it to `$filter` when `filter` is - // absent (the routed finding on #7751 verified the read point). Only the - // plural `filters` has zero read points. - // - // [#19514] The VALUE this pin carries moved, and the pin's subject did not. - // The key is still honoured and still parses; what changed is that it now - // carries `filter`'s own declaration — the same value in the same role, - // read through the same lowering sink — instead of `z.unknown()`. The AST - // tuple array this pin used to spell is one the pinned objectui grid - // APPLIES (`toFilterNode` passes it through and `parseFilterAST` accepts - // it), so its refusal here is a spelling change for the author, not the - // repair of a filter that failed. Its refusal is pinned below, and in full - // at `component-object-grid-default-filters.pin.test.ts`. - const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; - const parsed = ComponentPropsMap['object-grid'].parse({ - objectName: 'showcase_task', - defaultFilters: rules, - }); - expect(parsed.defaultFilters).toEqual(rules); - }); - - it('`defaultFilters` refuses the AST tuple array the `z.unknown()` door used to receipt', () => { - const r = ComponentPropsMap['object-grid'].safeParse({ - objectName: 'showcase_task', - defaultFilters: [['status', '=', 'open']], - }); - expect(r.success).toBe(false); - expect(r.error!.issues.some((i) => String(i.path[0]) === 'defaultFilters')).toBe(true); - }); + // `defaultFilters` — the grid's legacy base-filter fallback, read only when + // `filter` lowered to nothing — retired in v18 (#11509, ruling A-narrow, + // sub-question 1) in the shape `defaultSort` took below; #19514's rule-array + // narrowing of it is absorbed. Pinned in + // `element-flat-binding-retirement.test.ts`. // #11805 — the grid's legacy single-sort fallback, retired by maintainer // ruling 2026-08-25 (decision-inbox batch 4; the producer half of - // objectui#5861 under the objectui#4869 「接受所有」 direction). Unlike - // `defaultFilters` above — a read fallback that STAYS — `defaultSort` was + // objectui#5861 under the objectui#4869 「接受所有」 direction). Like + // `defaultFilters` above, which followed it in v18, `defaultSort` was // the second spelling of `sort` (read only when `sort` was absent, and // wrapped `[schema.defaultSort]` by the renderer's own header-arrow path), // so the one-intent-two-spellings rule retires it at the producer. diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts new file mode 100644 index 00000000000..cbf0259b636 --- /dev/null +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -0,0 +1,742 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11509 (v18, ruling A-narrow) — the element layer's flat data-binding keys + * and `object-grid.defaultFilters` RETIRED; an element binds data through the + * node-level `dataSource` only. + * + * Retired: `element:record_picker` `object` / `filter` / `sort` / `limit`, + * `element:number` `object` / `filter`, `element:repeater` `object` / `filter` + * / `sort` / `limit` — each the same query as a key of + * `ElementDataSourceSchema`, resolved per renderer by three contradictory rules + * (the picker let the binding win, `element:number` AND-combined the two + * filters, the repeater read the flat keys alone) — and `object-grid`'s + * `defaultFilters`, the legacy second spelling of `filter`. objectui moved the + * three renderers onto the binding first (objectui#11880), the order the + * ruling set. + * + * Bookkeeping shapes, pinned below: + * 1. `retiredKey()` tombstones carrying the prescription; the input type of + * each key is `never`. `PageComponentSchema.properties` is an open bag, so + * the props rows are reached by the component-props lint, never by the + * page parse. + * 2. D2 conversions `element-flat-data-binding-to-data-source` and + * `object-grid-default-filters-removed` (step 18), retired from the load + * path, ordered BEFORE `page-component-filter-record-to-rule-array`. + * 3. Eleven `RETIRED_KEYS_BY_MAJOR[18]` rows and one D3 entry per family; the + * three step-18 narrowings of these keys are absorbed. + * 4. A tree-scoped absence walk over the radius this package declares. + * + * The lint half — the missing-binding refusal that replaced the type-blind + * waiver, and the repeater trap — is pinned in + * `packages/lint/src/validate-component-props.test.ts`. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — no HTTP door parses these rows. + * So each refusal is pinned by the issue `code`, the `path` naming the key, and + * the prescription's first sentence. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice, ConversionTodoNotice } from '../conversions/types'; +import { applyMetaMigrations } from '../migrations/chain'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { normalizeStackInput } from '../shared/metadata-collection.zod'; +import { + ComponentPropsMap, + ElementNumberPropsSchema, + ElementRecordPickerPropsSchema, + ElementRepeaterPropsSchema, + ObjectGridPropsSchema, +} from './component.zod'; +import { PageComponentSchema } from './page.zod'; + +type Dict = Record; + +const ELEMENT_CONVERSION = 'element-flat-data-binding-to-data-source'; +const GRID_CONVERSION = 'object-grid-default-filters-removed'; +const RECORD_FORM_CONVERSION = 'page-component-filter-record-to-rule-array'; +const MIGRATE_SENTENCE = + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + +/** The retired keys, per element — what the ruling names, written out. */ +const RETIRED: Readonly> = { + 'element:record_picker': ['object', 'filter', 'sort', 'limit'], + 'element:number': ['object', 'filter'], + 'element:repeater': ['object', 'filter', 'sort', 'limit'], +}; +/** Each element's smallest clean props bag, the binding aside. */ +const MINIMAL: Readonly> = { + 'element:record_picker': {}, + 'element:number': { aggregate: 'count' }, + 'element:repeater': {}, +}; +/** A legal value of each retired key — what the binding takes at the same key. */ +const VALUE: Readonly> = { + object: 'deal', + filter: [{ field: 'stage', operator: 'equals', value: 'open' }], + sort: [{ field: 'amount', order: 'desc' }], + limit: 20, +}; +const CASES = Object.entries(RETIRED).flatMap(([type, keys]) => keys.map((key) => [type, key] as const)); + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Parse = { success: boolean; data?: unknown; error?: { issues: Issue[] } }; +const row = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as { + safeParse: (v: unknown) => Parse; + shape: Dict; +}; + +/** A one-component page, the component under test at `regions[0].components[0]`. */ +const pageWith = (component: Dict): Dict => ({ pages: [{ name: 'probe', regions: [{ name: 'main', components: [component] }] }] }); +const componentOf = (stack: Dict): Dict => + ((((stack.pages as Dict[])[0]!.regions as Dict[])[0]!.components as Dict[])[0]!); + +/** The whole chain, retired entries included — as the data-at-rest seams replay it. */ +function convert(stack: Dict): { stack: Dict; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversions(structuredClone(stack), { + includeRetired: true, + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }); + return { stack: out, notices, todos }; +} +const brief = (n: ConversionNotice) => [n.conversionId, n.path, n.from, n.to]; + +describe('the element tombstones — refused at the key, with the prescription', () => { + it.each(CASES)('%s `%s`', (type, key) => { + const r = row(type).safeParse({ ...MINIMAL[type], [key]: VALUE[key] }); + expect(r.success).toBe(false); + const issues = r.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('invalid_type'); + expect(issues[0]!.path).toEqual([key]); + const message = issues[0]!.message; + // House convention 1 + 2: the qualified key opens it, then the release and ADR. + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); + // The live mechanism, named at its own key on the node. + expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); + expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); + }); + + it('each element\'s prescription says what to do with a key the binding already sets — by that element\'s old rule', () => { + const message = (type: string, key: string) => + row(type).safeParse({ ...MINIMAL[type], [key]: VALUE[key] }).error!.issues[0]!.message; + expect(message('element:record_picker', 'limit')).toContain('delete this one, since the binding\'s value always won'); + expect(message('element:number', 'object')).toContain('delete this one, since the binding\'s value always won'); + expect(message('element:number', 'filter')).toContain('append these to it, since the two always AND-combined'); + expect(message('element:repeater', 'sort')).toContain('keep the value written here, which is the one the list honoured'); + }); + + it('refuses by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + const retired = row('element:number').safeParse({ aggregate: 'count', object: 'deal' }); + expect(retired.error!.issues.map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling comes back as `unrecognized_keys`. + const undeclared = row('element:number').safeParse({ aggregate: 'count', zzzNotAKey: 'deal' }); + expect(undeclared.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('the walked shapes keep each tombstone as a key — the authorable-surface `[RETIRED]` rows', () => { + for (const [type, keys] of Object.entries(RETIRED)) { + for (const key of keys) expect(Object.keys(row(type).shape), `${type}.${key}`).toContain(key); + } + }); + + it('fails tsc at the authoring site: the input type of each flat `object` is `never`', () => { + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:number`. + const number: z.input = { aggregate: 'count', object: 'deal' }; + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:record_picker`. + const picker: z.input = { object: 'deal' }; + // @ts-expect-error — `object` is a retiredKey() tombstone on `element:repeater`. + const repeater: z.input = { object: 'deal' }; + // The parse channel agrees with the type channel on the same literals. + for (const [schema, value] of [[ElementNumberPropsSchema, number], [ElementRecordPickerPropsSchema, picker], [ElementRepeaterPropsSchema, repeater]] as const) { + expect((schema as unknown as { safeParse: (v: unknown) => Parse }).safeParse(value).success).toBe(false); + } + }); + + it.each([ + ['objectName', 'object'], + ['filters', 'filter'], + ['where', 'filter'], + ['orderBy', 'sort'], + ['sortBy', 'sort'], + ['top', 'limit'], + ['pageSize', 'limit'], + ])('the repeater\'s `%s` spelling is pointed at the binding\'s `%s`, never at the tombstone', (spelling, key) => { + const r = row('element:repeater').safeParse({ [spelling]: VALUE[key] }); + const issue = r.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.message).toContain(`Write it as \`dataSource.${key}\` on the component node`); + expect(issue.message).not.toContain(`Did you mean \`${key}\``); + }); +}); + +describe('the binding — the one door the three elements read', () => { + it.each(Object.keys(RETIRED))('%s: the node with its query on `dataSource` parses, and its props grow no retired key', (type) => { + const binding = type === 'element:number' + ? { object: 'deal', view: 'won_deals', filter: VALUE.filter } + : { object: 'deal', view: 'hot_deals', filter: VALUE.filter, sort: VALUE.sort, limit: 20 }; + const r = PageComponentSchema.safeParse({ type, dataSource: binding, properties: MINIMAL[type] }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + const props = row(type).safeParse(MINIMAL[type]); + expect(props.success).toBe(true); + for (const key of RETIRED[type]!) expect(props.data as Dict).not.toHaveProperty(key); + }); + + it('never hard-refuses an existing page: the page parse still accepts a node carrying a flat key', () => { + // The open `properties` bag is the props lint's to judge (advisory), not the page parse's. + const r = PageComponentSchema.safeParse({ type: 'element:repeater', properties: { object: 'deal' } }); + expect(r.success).toBe(true); + }); +}); + +describe('`object-grid.defaultFilters` — refused at the key, with the prescription', () => { + it.each([ + ['a rule array', [{ field: 'status', operator: 'equals', value: 'open' }]], + ['the record form', { status: 'open' }], + ['an empty array', []], + ])('refuses %s', (_label, value) => { + const r = (ObjectGridPropsSchema as unknown as { safeParse: (v: unknown) => Parse }) + .safeParse({ objectName: 'deal', defaultFilters: value }); + expect(r.success).toBe(false); + const at = r.error!.issues.filter((i) => i.path[0] === 'defaultFilters'); + expect(at).toHaveLength(1); + expect(at[0]!.code).toBe('invalid_type'); + expect(at[0]!.path).toEqual(['defaultFilters']); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message).toContain('Use `filter`.'); + expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); + }); + + it('CONTROL: the live mechanism, `filter`, takes the same rule array', () => { + const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; + const r = (ObjectGridPropsSchema as unknown as { safeParse: (v: unknown) => Parse }).safeParse({ objectName: 'deal', filter: rules }); + expect(r.success).toBe(true); + expect(r.data as Dict).not.toHaveProperty('defaultFilters'); + }); +}); + +describe('`element-flat-data-binding-to-data-source` — each element\'s old rule, made mechanical', () => { + it('no binding: every flat key moves onto a new one, unchanged', () => { + for (const [type, keys] of Object.entries(RETIRED)) { + const flat = Object.fromEntries(keys.map((k) => [k, VALUE[k]])); + const { stack, notices, todos } = convert(pageWith({ type, properties: { ...MINIMAL[type], ...flat } })); + const component = componentOf(stack); + expect(component.properties, type).toEqual(MINIMAL[type]); + expect(component.dataSource, type).toEqual(flat); + expect(notices.map(brief), type).toEqual(keys.map((k) => [ + ELEMENT_CONVERSION, `pages[0].regions[0].components[0].dataSource.${k}`, `properties.${k}`, `dataSource.${k}`, + ])); + expect(todos, type).toEqual([]); + // What the conversion writes is what the node door takes. + expect(PageComponentSchema.safeParse(component).success, type).toBe(true); + expect(row(type).safeParse(component.properties).success, type).toBe(true); + } + }); + + it('the record picker: a key the binding already set is DELETED (the binding always won), a missing one moves', () => { + const { stack, notices } = convert(pageWith({ + type: 'element:record_picker', + dataSource: { object: 'deal', limit: 10 }, + properties: { object: 'lead', limit: 50, sort: VALUE.sort, labelField: 'name' }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', limit: 10, sort: VALUE.sort }); + expect(component.properties).toEqual({ labelField: 'name' }); + expect(notices.map((n) => [n.path, n.to])).toEqual([ + ['pages[0].regions[0].components[0].properties.object', '(removed)'], + ['pages[0].regions[0].components[0].dataSource.sort', 'dataSource.sort'], + ['pages[0].regions[0].components[0].properties.limit', '(removed)'], + ]); + }); + + it('the record picker beside a saved `view`: a key the binding lacks is a TODO, left as stored', () => { + const before = pageWith({ + type: 'element:record_picker', + id: 'picker', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { filter: VALUE.filter, limit: 20 }, + }); + const { stack, notices, todos } = convert(before); + expect(componentOf(stack)).toEqual(componentOf(before)); + expect(notices).toEqual([]); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + [ELEMENT_CONVERSION, 'pages[0].regions[0].components[0].properties.filter'], + [ELEMENT_CONVERSION, 'pages[0].regions[0].components[0].properties.limit'], + ]); + expect(todos[0]!.reason).toMatch(/^On the `element:record_picker` block `picker`, this flat `filter` sits beside `dataSource\.view: 'hot_deals'`/); + expect(todos[0]!.reason.endsWith('Left as stored, this key reaches no query.')).toBe(true); + // …while its `object` is decided whatever the view says: the binding names one. + const objectToo = convert(pageWith({ + type: 'element:record_picker', + dataSource: { object: 'deal', view: 'hot_deals' }, + properties: { object: 'deal' }, + })); + expect(objectToo.todos).toEqual([]); + expect(componentOf(objectToo.stack).properties).toEqual({}); + }); + + it('`element:number`: the two filters AND-combined, so the flat rules are APPENDED to the binding\'s', () => { + const own = [{ field: 'stage', operator: 'equals', value: 'won' }]; + const { stack, notices } = convert(pageWith({ + type: 'element:number', + dataSource: { object: 'deal', filter: own, view: 'this_quarter' }, + properties: { aggregate: 'count', object: 'lead', filter: VALUE.filter }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', view: 'this_quarter', filter: [...own, ...(VALUE.filter as Dict[])] }); + expect(component.properties).toEqual({ aggregate: 'count' }); + expect(notices.map((n) => n.to)).toEqual(['(removed)', 'dataSource.filter (rules appended; they AND)']); + }); + + it('`element:number`: a filter pair that is not two rule arrays is a TODO, left as stored', () => { + const before = pageWith({ + type: 'element:number', + dataSource: { object: 'deal', filter: [['stage', '=', 'won']] }, + properties: { aggregate: 'count', filter: VALUE.filter }, + }); + const { stack, todos } = convert(before); + expect((componentOf(stack).properties as Dict).filter).toEqual(VALUE.filter); + expect(todos.filter((t) => t.conversionId === ELEMENT_CONVERSION).map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.filter', + ]); + }); + + it('the repeater: it read the flat keys alone, so an EQUAL binding value is deleted, a DIFFERENT one or a `view` is a TODO', () => { + const { stack, notices, todos } = convert(pageWith({ + type: 'element:repeater', + dataSource: { object: 'deal', view: 'open_deals', limit: 5 }, + properties: { object: 'deal', limit: 10, filter: VALUE.filter, titleField: 'name' }, + })); + const component = componentOf(stack); + expect(component.dataSource).toEqual({ object: 'deal', view: 'open_deals', limit: 5 }); + expect(component.properties).toEqual({ limit: 10, filter: VALUE.filter, titleField: 'name' }); + expect(notices.map((n) => [n.path, n.to])).toEqual([['pages[0].regions[0].components[0].properties.object', '(removed)']]); + expect(todos.map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.filter', + 'pages[0].regions[0].components[0].properties.limit', + ]); + expect(todos[0]!.reason.startsWith('On the `element:repeater` block, the binding names the saved view `open_deals`')).toBe(true); + expect(todos[1]!.reason.startsWith('On the `element:repeater` block, `dataSource.limit` is set to a different value')).toBe(true); + }); + + it('is scoped by component TYPE: the same keys on another element are not this entry\'s', () => { + const before = pageWith({ type: 'element:metadata_viewer', properties: { type: 'flow', name: 'approve', object: 'deal' } }); + const { stack, notices } = collectConversionNotices(before, { includeRetired: true }); + expect(notices).toEqual([]); + expect(stack).toBe(before); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ELEMENT_CONVERSION)!; + const once = collectConversionNotices(structuredClone(entry.fixture.before), { includeRetired: true }); + const twice = collectConversionNotices(once.stack, { includeRetired: true }); + expect(twice.notices).toEqual([]); + expect(twice.stack).toBe(once.stack); + }); + + it('the list it moves is the spec\'s own: exactly the keys `ComponentPropsMap` tombstones onto `dataSource.`', () => { + const tombstoned: string[] = []; + const moved: string[] = []; + for (const type of Object.keys(ComponentPropsMap)) { + for (const key of ['object', 'filter', 'sort', 'limit']) { + const r = row(type).safeParse({ [key]: VALUE[key] }); + const at = r.success ? [] : r.error!.issues.filter((i) => i.path.length === 1 && i.path[0] === key); + if (at.some((i) => i.message.includes('was removed') && i.message.includes(`\`dataSource.${key}\``))) { + tombstoned.push(`${type}:${key}`); + } + const { stack } = convert(pageWith({ type, properties: { [key]: VALUE[key] } })); + if ((componentOf(stack).dataSource as Dict | undefined)?.[key] !== undefined) moved.push(`${type}:${key}`); + } + } + expect(tombstoned.sort()).toEqual(CASES.map(([t, k]) => `${t}:${k}`).sort()); + expect(moved.sort()).toEqual(tombstoned.sort()); + }); +}); + +describe('`object-grid-default-filters-removed` — the shape `defaultSort`\'s retirement took', () => { + const RULES = [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]; + const grid = (properties: Dict) => pageWith({ type: 'object-grid', id: 'g', properties: { objectName: 'deal', ...properties } }); + + it.each([ + ['absent', {}], + ['null', { filter: null }], + ['an empty rule array', { filter: [] }], + ['an empty record', { filter: {} }], + ])('`filter` %s — the fallback WAS the filter, so it moves', (_label, filter) => { + const { stack, notices } = convert(grid({ ...filter, defaultFilters: RULES })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal', filter: RULES }); + expect(notices.map(brief)).toEqual([[GRID_CONVERSION, 'pages[0].regions[0].components[0].properties.filter', 'defaultFilters', 'filter']]); + }); + + it.each([ + ['a rule array', [{ field: 'status', operator: 'equals', value: 'open' }]], + ['the record form', { status: 'open' }], + ])('`filter` with content (%s) — the fallback was never read, so it is deleted', (_label, filter) => { + const { stack, notices } = convert(grid({ filter, defaultFilters: RULES })); + expect(componentOf(stack).properties).not.toHaveProperty('defaultFilters'); + expect(notices.filter((n) => n.conversionId === GRID_CONVERSION).map((n) => [n.path, n.to])).toEqual([ + ['pages[0].regions[0].components[0].properties.defaultFilters', '(removed)'], + ]); + }); + + it('an empty fallback carries nothing, so it is deleted whatever `filter` holds', () => { + const { stack } = convert(grid({ defaultFilters: [] })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal' }); + }); + + it('`filter` a value no lowering reads — a TODO, left as stored, never overwritten', () => { + const before = grid({ filter: 'status = open', defaultFilters: RULES }); + const { stack, notices, todos } = convert(before); + expect(componentOf(stack)).toEqual(componentOf(before)); + expect(notices).toEqual([]); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([[GRID_CONVERSION, 'pages[0].regions[0].components[0].properties.defaultFilters']]); + }); + + it('runs BEFORE the record-form conversion: a record-form fallback moves onto `filter` and is converted there', () => { + const order = CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id); + expect(order.indexOf(GRID_CONVERSION)).toBeLessThan(order.indexOf(RECORD_FORM_CONVERSION)); + expect(order.indexOf(ELEMENT_CONVERSION)).toBeLessThan(order.indexOf(RECORD_FORM_CONVERSION)); + const { stack, notices } = convert(grid({ defaultFilters: { owner_id: '{current_user_id}' } })); + expect(componentOf(stack).properties).toEqual({ objectName: 'deal', filter: RULES }); + expect(notices.map((n) => n.conversionId)).toEqual([GRID_CONVERSION, RECORD_FORM_CONVERSION]); + }); + + it('is scoped by component TYPE: `defaultFilters` on another block is not this entry\'s', () => { + const before = pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: RULES } }); + const { stack } = collectConversionNotices(before, { includeRetired: true }); + expect(stack).toBe(before); + }); +}); + +describe('the jurisdiction — retired from authoring, replayed at rest and by the chain', () => { + const page = { + name: 'deal_desk', + regions: [{ + name: 'main', + components: [ + { type: 'element:repeater', properties: { object: 'deal', limit: 5 } }, + { type: 'object-grid', properties: { objectName: 'deal', defaultFilters: [{ field: 'stage', operator: 'equals', value: 'open' }] } }, + ], + }], + }; + + it('⛔ the authoring funnel does not replay either — an author is refused at the parse instead', () => { + const notices: ConversionNotice[] = []; + const out = normalizeStackInput({ pages: [structuredClone(page)] }, { onConversionNotice: (n) => notices.push(n) }); + expect((out.pages as Dict[])[0]).toEqual(page); + expect(notices.filter((n) => n.conversionId === ELEMENT_CONVERSION || n.conversionId === GRID_CONVERSION)).toEqual([]); + }); + + it('the stored-row seam replays both', () => { + const stored = applyConversionsToStoredItem('page', structuredClone(page)) as typeof page; + const [repeater, gridNode] = stored.regions[0]!.components as Dict[]; + expect(repeater).toEqual({ type: 'element:repeater', properties: {}, dataSource: { object: 'deal', limit: 5 } }); + expect(gridNode!.properties).toEqual({ objectName: 'deal', filter: [{ field: 'stage', operator: 'equals', value: 'open' }] }); + }); + + it('`os migrate meta --from 17` replays both and lists the edits', () => { + const result = applyMetaMigrations({ pages: [structuredClone(page)] }, 17, 18); + const applied = result.applied.map((a) => a.conversionId); + expect(applied.filter((id) => id === ELEMENT_CONVERSION)).toHaveLength(2); + expect(applied.filter((id) => id === GRID_CONVERSION)).toHaveLength(1); + }); +}); + +describe('the ADR-0087 ledger rows', () => { + it('declares the eleven keys under major 18, and no other major', () => { + const keys = [ + ...CASES.map(([type, key]) => { + const def = { 'element:record_picker': 'ElementRecordPickerProps', 'element:number': 'ElementNumberProps', 'element:repeater': 'ElementRepeaterProps' }[type]!; + return `ui/${def}:${key}`; + }), + 'ui/ObjectGridProps:defaultFilters', + ]; + expect(keys).toHaveLength(11); + for (const key of keys) { + expect(RETIRED_KEYS_BY_MAJOR[18], key).toContain(key); + for (const [major, list] of Object.entries(RETIRED_KEYS_BY_MAJOR)) { + if (major !== '18') expect(list, `${key} @ ${major}`).not.toContain(key); + } + } + }); + + it('wires both D2 conversions into step 18 as retired, stamped entries', () => { + for (const id of [ELEMENT_CONVERSION, GRID_CONVERSION]) { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(id); + const conversion = ALL_CONVERSIONS.find((c) => c.id === id)!; + expect(conversion.toMajor).toBe(18); + expect(conversion.retiredFromLoadPath).toBe(true); + } + }); + + it('carries one D3 entry per family, naming its D2 conversion — and the absorbed narrowings are gone', () => { + const semantic = MIGRATIONS_BY_MAJOR[18]!.semantic; + for (const [id, conversion] of [['element-flat-data-binding-retired', ELEMENT_CONVERSION], ['object-grid-default-filters-retired', GRID_CONVERSION]] as const) { + const entries = semantic.filter((s) => s.id === id); + expect(entries, id).toHaveLength(1); + expect(entries[0]!.conversionIds).toEqual([conversion]); + expect(entries[0]!.reason).toContain(`\`${conversion}\``); + expect(entries[0]!.acceptanceCriteria.length).toBeGreaterThan(0); + } + const everyId = Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic.map((s) => s.id)); + for (const absorbed of ['object-grid-default-filters-rule-array', 'element-number-filter-rule-array', 'element-record-picker-filter-rule-array']) { + expect(everyId, absorbed).not.toContain(absorbed); + } + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so it does not reach a node authored through +// `definePage` / `defineStack`, a YAML fence or a JSON export at all. This walk +// covers every text file under the repo roots `scripts/cross-package-test-inputs.mjs` +// declares for `@objectstack/spec#test` (mirrored in `turbo.json`) — the radius +// the sibling retirement pins walk — plus the example apps' own `src/` trees. +// +// The matchers judge the AUTHORING SHAPE, never a mention: +// - an element node — `type` naming one of the three elements — whose +// `properties` carries a retired key at its own level: in an object +// literal or JSON (shorthand included), in YAML by indentation, and in a +// JSX / HTML tag's attributes; +// - `defaultFilters` in key position with a literal value (`[`, `{`, or a +// YAML block), or as a tag attribute. +// The keys themselves are four of the most common words in metadata, so a +// bare-key matcher would be noise: the element node is the anchor. Inline code +// spans are prose and are stripped before judging; fenced examples are judged. +// The bound, stated: a node whose `type` is not a literal, or whose +// `properties` is built elsewhere and spread in, and the `docs/**`, +// `.claude/**`, `.github/**` and repo-root files, are outside what this walk +// sees. +describe('tree-scoped absence: nothing inside the declared radius still authors a retired key', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml', '.html']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const ELEMENT = 'element:(?:record_picker|number|repeater)'; + const KEYS = '(?:object|filter|sort|limit)'; + /** `type: 'element:…'` in an object literal or JSON. */ + const LITERAL_TYPE = new RegExp(`(^|[^\\w.$])["']?type["']?[ \\t]*:[ \\t]*["']${ELEMENT}["']`, 'gm'); + /** `type: element:…` as a YAML mapping key, its indentation captured (a list dash counts as indent). */ + const YAML_TYPE = new RegExp(`^([ \\t]*(?:-[ \\t]+)?)type:[ \\t]*["']?${ELEMENT}["']?[ \\t]*$`, 'gm'); + /** A JSX / HTML tag naming one of the three elements. */ + const TAG = new RegExp(`<[A-Za-z][\\w.:-]*\\b[^<>]*\\btype=["']${ELEMENT}["'][^<>]*>`, 'g'); + /** A retired key at a literal's own level: `key:` (quoted or bare), or a shorthand `key`. */ + const OWN_LEVEL_KEY = new RegExp(`(^|[^\\w.$])["']?${KEYS}["']?[ \\t]*:|(^|,)[ \\t\\n]*${KEYS}[ \\t\\n]*(?=,|$)`); + const DEFAULT_FILTERS = /(^|[^\w.$])["']?defaultFilters["']?[ \t]*:[ \t]*(\[|\{|$)|\sdefaultFilters=/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + + /** The `{` that opens the object literal enclosing `at`, or -1. */ + const literalStart = (text: string, at: number): number => { + for (let i = at - 1, depth = 0; i >= 0; i -= 1) { + const c = text[i]; + if (c === '}' || c === ']') depth += 1; + else if (c === '{' || c === '[') { + if (depth === 0) return c === '{' ? i : -1; + depth -= 1; + } + } + return -1; + }; + /** The index of the bracket closing the one opened at `open`, or the text's end. */ + const groupEnd = (text: string, open: number): number => { + for (let i = open + 1, depth = 0; i < text.length; i += 1) { + const c = text[i]; + if (c === '{' || c === '[') depth += 1; + else if (c === '}' || c === ']') { + if (depth === 0) return i; + depth -= 1; + } + } + return text.length; + }; + /** + * A group's own level: its text with every nested `{…}` / `[…]` removed, and + * every quoted VALUE emptied (a quoted string not followed by `:`), so a + * string that merely contains `filter:` is not read as the key. + */ + const ownLevel = (text: string, open: number): string => { + const end = groupEnd(text, open); + let own = ''; + for (let i = open + 1, depth = 0; i < end; i += 1) { + const c = text[i]!; + if (c === '{' || c === '[') depth += 1; + else if (c === '}' || c === ']') depth -= 1; + else if (depth === 0) own += c; + } + return own.replace(/(["'])(?:\\.|(?!\1)[^\n])*\1(?![ \t]*:)/g, "''"); + }; + /** The `properties` group of the literal opened at `start`, judged at its own level. */ + const literalPropsAuthorRetired = (text: string, start: number): boolean => { + const end = groupEnd(text, start); + const PROPS = /["']?properties["']?[ \t]*:[ \t]*\{/g; + for (let depth = 0, i = start + 1; i < end; i += 1) { + const c = text[i]; + if (c === '{' || c === '[') { depth += 1; continue; } + if (c === '}' || c === ']') { depth -= 1; continue; } + if (depth !== 0) continue; + PROPS.lastIndex = i; + const m = PROPS.exec(text); + if (m && m.index === i && /[^\w.$]/.test(text[i - 1] ?? ' ')) { + return OWN_LEVEL_KEY.test(ownLevel(text, i + m[0].length - 1)); + } + } + return false; + }; + /** In YAML, the `properties` mapping beside the `type` line, judged by indentation. */ + const yamlPropsAuthorRetired = (lines: readonly string[], typeLine: number, keyIndent: number): boolean => { + for (let i = typeLine + 1; i < lines.length; i += 1) { + const line = lines[i]!; + if (line.trim() === '') continue; + const indent = line.length - line.trimStart().length; + if (indent < keyIndent) return false; + if (indent !== keyIndent) continue; + const props = /^properties:[ \t]*(.*)$/.exec(line.trimStart()); + if (!props) continue; + if (props[1]!.startsWith('{')) return OWN_LEVEL_KEY.test(ownLevel(props[1]!, 0)); + for (let j = i + 1, child = -1; j < lines.length; j += 1) { + const inner = lines[j]!; + if (inner.trim() === '') continue; + const innerIndent = inner.length - inner.trimStart().length; + if (innerIndent <= keyIndent) return false; + if (child < 0) child = innerIndent; + if (innerIndent === child && new RegExp(`^${KEYS}:`).test(inner.trimStart())) return true; + } + return false; + } + return false; + }; + + const judge = (raw: string): string | null => { + const text = stripInlineCode(raw); + for (const m of text.matchAll(LITERAL_TYPE)) { + const start = literalStart(text, m.index! + m[1]!.length); + if (start >= 0 && literalPropsAuthorRetired(text, start)) return m[0].trim(); + } + const lines = text.split('\n'); + for (const m of text.matchAll(YAML_TYPE)) { + const typeLine = text.slice(0, m.index!).split('\n').length - 1; + if (yamlPropsAuthorRetired(lines, typeLine, m[1]!.length)) return m[0].trim(); + } + for (const m of text.matchAll(TAG)) { + if (new RegExp(`\\s${KEYS}=`).test(m[0])) return m[0]; + } + const grid = DEFAULT_FILTERS.exec(text); + return grid ? grid[0].trim() : null; + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell a retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the keys to assert their refusal and their conversion. + THIS_FILE, + // The props-lint pin authors them to assert the finding an author meets. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversions' fixtures and tests author the pre-retirement shapes on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output, reached only because this is a FILESYSTEM walk. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matchers recognise an authoring in every syntax, and ignore a mention, a binding and a declaration (anti-vacuity)', () => { + // Offenders. + expect(judge("{ type: 'element:record_picker', properties: { object: 'deal', labelField: 'name' } }")).not.toBeNull(); + expect(judge("{\n type: 'element:number',\n id: 'kpi',\n properties: {\n aggregate: 'count',\n filter: [],\n },\n}")).not.toBeNull(); + expect(judge("{ properties: { limit, titleField: 'name' }, type: 'element:repeater' }")).not.toBeNull(); + expect(judge('{ "type": "element:repeater", "properties": { "sort": [] } }')).not.toBeNull(); + expect(judge('components:\n - type: element:number\n properties:\n aggregate: count\n object: deal\n')).not.toBeNull(); + expect(judge(' - type: element:repeater\n properties: { object: deal }\n')).not.toBeNull(); + expect(judge('')).not.toBeNull(); + expect(judge("{ type: 'object-grid', properties: { objectName: 'deal', defaultFilters: [] } }")).not.toBeNull(); + expect(judge('object-grid:\n defaultFilters:\n - field: stage\n')).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 50 }, properties: { labelField: 'name' } }")).toBeNull(); + expect(judge("{ type: 'element:number', properties: { aggregate: 'count', format: 'number' }, dataSource: { object: 'deal', filter: [] } }")).toBeNull(); + expect(judge("{ type: 'element:repeater', properties: { fields: [{ field: 'object' }], emptyText: 'filter: none' } }")).toBeNull(); + expect(judge("{ type: 'element:metadata_viewer', properties: { type: 'flow', name: 'x', object: 'deal' } }")).toBeNull(); + expect(judge("{ type: 'object-grid', properties: { objectName: 'deal', filter: [], sort: [], limit: 5 } }")).toBeNull(); + expect(judge('a flat `properties: { object: deal }` on an `element:number` is refused')).toBeNull(); + expect(judge(' - type: element:number\n dataSource:\n object: deal\n properties:\n aggregate: count\n')).toBeNull(); + expect(judge('defaultFilters: retiredKey(\n')).toBeNull(); + expect(judge("const { defaultFilters, ...rest } = properties;")).toBeNull(); + expect(judge('"ui/ObjectGridProps:defaultFilters",')).toBeNull(); + }); + + it('no retired key is authored inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const hit = judge(text); + if (hit) offenders.push(`${rel} authors \`${hit.replace(/\s+/g, ' ').slice(0, 120)}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'an authored retired key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts index bd018cbc641..9688b2c53a2 100644 --- a/packages/spec/src/ui/filter-rule-array-guidance.test.ts +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -1,8 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The ten converged rule-array `filter` doors name the new spelling when - * they refuse the old one. + * The converged rule-array `filter` doors name the new spelling when they + * refuse the old one — eight of them since v18, when `element:number`'s and + * `element:record_picker`'s flat `filter` (two of the original ten) retired + * onto the binding's own door, `dataSource.filter` (#11509); a retired door + * answers every value with its removal prescription instead. * * Seven doors converged on `z.array(ViewFilterRuleSchema)` (the objectui#6206 * family) and each one refused the record form an author used to write with a @@ -39,7 +42,7 @@ import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const RECORD_FORM = { status: 'active' } as const; /** - * The ten doors, each with enough sibling props to reach a clean reading — + * The doors, each with enough sibling props to reach a clean reading — * the other required keys are filled so the only issue under test is `filter`. * * Seven at the convergence; `object-map`, `object-gantt` and `object-tree` @@ -105,19 +108,6 @@ const DOORS: readonly { migration: 'element-data-source-and-object-block-filter-rule-array', parse: (filter) => ComponentPropsMap['object-tree'].safeParse({ objectName: 'task', filter }), }, - { - name: "ComponentPropsMap['element:number'].filter", - surface: 'this `element:number`', - migration: 'element-number-filter-rule-array', - parse: (filter) => - ComponentPropsMap['element:number'].safeParse({ object: 'task', aggregate: 'count', filter }), - }, - { - name: "ComponentPropsMap['element:record_picker'].filter", - surface: 'this `element:record_picker`', - migration: 'element-record-picker-filter-rule-array', - parse: (filter) => ComponentPropsMap['element:record_picker'].safeParse({ object: 'task', filter }), - }, ]; /** The one issue raised at the `filter` key itself. */ From feec7a01c7d2d7265323081a763bf7855211b162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:05:33 +0000 Subject: [PATCH 05/14] wip: changeset for the element-binding retirement Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../11509-element-flat-binding-retired.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/11509-element-flat-binding-retired.md diff --git a/.changeset/11509-element-flat-binding-retired.md b/.changeset/11509-element-flat-binding-retired.md new file mode 100644 index 00000000000..cb03d665eb1 --- /dev/null +++ b/.changeset/11509-element-flat-binding-retired.md @@ -0,0 +1,42 @@ +--- +'@objectstack/spec': major +'@objectstack/lint': major +--- + +feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + +Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + +**Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + +**What changes.** + +- **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. +- **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. +- **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | +| `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | +| `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | +| `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | +| `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + +**The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + +**For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + +**Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + +### The retirement kit + +- `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. +- Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. +- Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. From 418d40d9b0cf18ae698434280bbca98753b8d68c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:30:12 +0000 Subject: [PATCH 06/14] wip: tombstones name the published major; register the repo-scoped pin Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/lint/src/validate-component-props.test.ts | 4 ++-- packages/spec/src/ui/component.zod.ts | 4 ++-- packages/spec/src/ui/element-flat-binding-retirement.test.ts | 4 ++-- packages/spec/vitest.repo-tests.json | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 3315e8caf44..5ecf6ddf37c 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -379,7 +379,7 @@ describe('validateComponentProps — value verdicts', () => { 'pages[0].regions[0].components[0].properties.object', ]); const tombstone = invalid(flat).find((f) => f.path.endsWith('.properties.object'))!; - expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 18.*`dataSource\.object`/s); + expect(tombstone.message).toMatch(/`element:repeater` property `object` was removed in @objectstack\/spec 17.*`dataSource\.object`/s); }); /** @@ -483,7 +483,7 @@ describe('validateComponentProps — value verdicts', () => { for (const key of ['object', 'filter', 'sort', 'limit']) { const at = invalid(findings).find((f) => f.path === `${base}.properties.${key}`)!; expect(at.message).toMatch( - new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 18.*\`dataSource\\.${key}\``, 's'), + new RegExp(`\`element:record_picker\` property \`${key}\` was removed in @objectstack/spec 17.*\`dataSource\\.${key}\``, 's'), ); } expect(unknownKeys(findings)).toEqual([]); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e9e8c0546f2..c4a1c07bbb0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2648,7 +2648,7 @@ const elementFlatBindingRetired = ( : type === 'element:number' && key === 'filter' ? 'where `dataSource.filter` already has rules, append these to it, since the two always AND-combined' : 'where `dataSource` already sets it, delete this one, since the binding\'s value always won'; - return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — ` + return `\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — ` + `${why}, so a value written here reaches no query. Use \`dataSource.${key}\` on the component ` + 'node, a sibling of `type` rather than a key inside `properties`. Move the key; the value ' + `(${ELEMENT_FLAT_BINDING_VALUE[key]}) is unchanged, and ${both}. ` @@ -4611,7 +4611,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * a record-form value it moves is converted at `filter` like any other. */ defaultFilters: retiredKey( - '`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + '`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ' + 'it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to ' + 'nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use ' + '`filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index cbf0259b636..2e43a2066f9 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -124,7 +124,7 @@ describe('the element tombstones — refused at the key, with the prescription', expect(issues[0]!.path).toEqual([key]); const message = issues[0]!.message; // House convention 1 + 2: the qualified key opens it, then the release and ADR. - expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 18 (ADR-0087 D2) — `)).toBe(true); + expect(message.startsWith(`\`${type}\` property \`${key}\` was removed in @objectstack/spec 17 (ADR-0087 D2) — `)).toBe(true); // The live mechanism, named at its own key on the node. expect(message).toContain(`Use \`dataSource.${key}\` on the component node, a sibling of \`type\``); expect(message.endsWith(MIGRATE_SENTENCE)).toBe(true); @@ -214,7 +214,7 @@ describe('`object-grid.defaultFilters` — refused at the key, with the prescrip expect(at).toHaveLength(1); expect(at[0]!.code).toBe('invalid_type'); expect(at[0]!.path).toEqual(['defaultFilters']); - expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — ')).toBe(true); + expect(at[0]!.message.startsWith('`object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — ')).toBe(true); expect(at[0]!.message).toContain('Use `filter`.'); expect(at[0]!.message.endsWith(MIGRATE_SENTENCE)).toBe(true); }); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index a327509d614..aded290beee 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -46,6 +46,7 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/element-flat-binding-retirement.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", "src/ui/master-detail-detail-sort-field-retirement.test.ts", From cb72be3d709d506ac36de4b6ee5009fa810511a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:50:19 +0000 Subject: [PATCH 07/14] wip(spec): re-bind the element fixtures the absence walk found; repeater pins follow the binding Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- ...ponent-filter-record-to-rule-array.test.ts | 10 ++++- .../spec/src/system/i18n-resolver.test.ts | 2 +- ...omponent-action-element-rows-20371.test.ts | 31 +++++++++++----- packages/spec/src/ui/page.test.ts | 37 ++++++++++--------- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 07257fca72a..40b968c5536 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -558,7 +558,15 @@ describe('§6 the reach is the family, read off the schema', () => { // #11509 retired `object-grid.defaultFilters` (the one block that had it): // the row answers every value with its removal prescription, never the // rule-array one, and no block keeps a record form there after the chain. - expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters'))).toEqual([]); + // (Its tombstone quotes the rule form as the value to move, so the probe + // is the removal sentence, not the rule-array prescription's form text.) + const retired = TYPES.filter((t) => { + const parse = (ComponentPropsMap[t] as unknown as { safeParse: (v: unknown) => { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }).safeParse; + const r = parse.call(ComponentPropsMap[t], { defaultFilters: { status: 'active' } }); + return !r.success && r.error!.issues.some((i) => i.path[0] === 'defaultFilters' && i.message.includes('was removed')); + }); + expect(retired).toEqual(['object-grid']); + expect(TYPES.filter((t) => refusesRecordWithPrescription(ComponentPropsMap[t], 'defaultFilters') && !retired.includes(t))).toEqual([]); expect(TYPES.filter((t) => converts(t, 'defaultFilters'))).toEqual([]); }); diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 7844e25eb27..c812594ce6a 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1605,7 +1605,7 @@ describe('translatePage', () => { { type: 'page:card', id: 'quick_create', properties: { title: 'Quick Create', icon: 'plus' } }, { type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } }, { type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } }, - { type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } }, + { type: 'element:record_picker', id: 'lead_picker', dataSource: { object: 'lead' }, properties: { placeholder: 'Search leads…', emptyText: 'No records' } }, // Was `element:form` until #9249 retired that element whole, then a // bespoke type carrying the `submitLabel` pin until commit d173125fb, whose // ruling retired the key from the copy face, so the node now pins diff --git a/packages/spec/src/ui/component-action-element-rows-20371.test.ts b/packages/spec/src/ui/component-action-element-rows-20371.test.ts index 3b0810242b3..d8d867a3f9a 100644 --- a/packages/spec/src/ui/component-action-element-rows-20371.test.ts +++ b/packages/spec/src/ui/component-action-element-rows-20371.test.ts @@ -30,7 +30,7 @@ import { hasReservedComponentNamespace, isKnownComponentType, } from './component-type-vocabulary'; -import { PageComponentSchema, PageComponentType } from './page.zod'; +import { ElementDataSourceSchema, PageComponentSchema, PageComponentType } from './page.zod'; type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; @@ -281,14 +281,20 @@ describe('what the measurement decided, pinned', () => { expect(ElementDefinitionListPropsSchema.safeParse({}).success).toBe(true); }); - it('repeater `object` is required — without it the list never queries', () => { - const issues = issuesOf(ElementRepeaterPropsSchema.safeParse({ fields: ['name'] })); + it('repeater: its object is the binding\'s since v18 — the props bag requires none, and refuses a flat one', () => { + // The measurement made the flat `object` REQUIRED (without it the list + // never queries). #11509 moved the list onto the node-level `dataSource`, + // so the requirement moved with it: the component-props lint reports a + // repeater with no `dataSource.object`, and the props bag refuses `object`. + expect(ElementRepeaterPropsSchema.safeParse({ fields: ['name'] }).success).toBe(true); + const issues = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', fields: ['name'] })); expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'object']]); + expect(issues[0]!.message).toContain('`dataSource.object`'); }); it('repeater `fields` takes a name or `{ field }`; the unrendered `label` is refused inside the union', () => { - expect(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: ['a', { field: 'b' }] }).success).toBe(true); - const [union] = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 't', fields: [{ field: 'b', label: 'B' }] })); + expect(ElementRepeaterPropsSchema.safeParse({ fields: ['a', { field: 'b' }] }).success).toBe(true); + const [union] = issuesOf(ElementRepeaterPropsSchema.safeParse({ fields: [{ field: 'b', label: 'B' }] })); expect(union!.code).toBe('invalid_union'); expect(union!.path).toEqual(['fields', 0]); // Exactly one arm judged keys, and only keys — the shape the props gate @@ -298,18 +304,25 @@ describe('what the measurement decided, pinned', () => { expect(keyArms[0]!.map((i) => i.keys)).toEqual([['label']]); }); - it('repeater `filter` / `sort` are the family\'s one orthography — the record form is refused', () => { - const ok = ElementRepeaterPropsSchema.safeParse({ + it('repeater `filter` / `sort` / `limit` are the binding\'s since v18, in the family\'s one orthography', () => { + // Born on the rule array and the sort array; #11509 moved all three onto + // the node-level `dataSource`, which carries the same shapes. + const ok = ElementDataSourceSchema.safeParse({ object: 'task', filter: [{ field: 'status', operator: 'equals', value: 'open' }], sort: [{ field: 'due_date', order: 'asc' }], limit: 10, }); expect(ok.success).toBe(true); - const record = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', filter: { status: 'open' } })); + const record = issuesOf(ElementDataSourceSchema.safeParse({ object: 'task', filter: { status: 'open' } })); expect(record.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'filter']]); - const limit = issuesOf(ElementRepeaterPropsSchema.safeParse({ object: 'task', limit: 0 })); + const limit = issuesOf(ElementDataSourceSchema.safeParse({ object: 'task', limit: 0 })); expect(limit.map((i) => [i.code, i.path.join('.')])).toEqual([['too_small', 'limit']]); + // …and the flat keys are refused at the props bag, with the prescription. + for (const key of ['filter', 'sort', 'limit']) { + const flat = issuesOf(ElementRepeaterPropsSchema.safeParse({ [key]: [] })); + expect(flat.map((i) => [i.code, i.path.join('.')]), key).toEqual([['invalid_type', key]]); + } }); it('`objectName` is declared where the renderer forwards it — on the action, never on a container', () => { diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 02a89fb8280..d625aa87bf2 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -591,7 +591,7 @@ describe('PageSchema with page types', () => { { name: 'main', components: [ - { type: 'element:number', properties: { object: 'order', aggregate: 'count' } }, + { type: 'element:number', dataSource: { object: 'order' }, properties: { aggregate: 'count' } }, ], }, ], @@ -774,27 +774,29 @@ describe('ElementDataSourceSchema `filter` — one filter orthography platform-w it('shares the array orthography with the props-map `filter` doors — one value, two keys, the same verdicts', () => { // `element:record_picker` was the node that carried two orthographies at // two keys (`properties.filter` the array, `dataSource.filter` the record) - // resolved through one `??` in the renderer. Each key is asked at ITS - // door: the binding through the real `PageComponentSchema` (which parses - // `dataSource` and leaves `properties` a bag — the props-map dispatch is - // the lint's, warning tier), and the props key through the picker's own - // `ComponentPropsMap` entry. The same rule array raises no issue at either; - // the same record is refused at both with the same code. + // resolved through one `??` in the renderer; since v18 (#11509) its flat + // `filter` is retired and the binding is its one door. Each key is asked + // at ITS door: the binding through the real `PageComponentSchema` (which + // parses `dataSource` and leaves `properties` a bag — the props-map + // dispatch is the lint's, warning tier), and a live props-map door — + // `object-grid`'s `filter` — through its own `ComponentPropsMap` entry. + // The same rule array raises no issue at either; the same record is + // refused at both with the same code. const binding = PageComponentSchema.safeParse({ type: 'element:record_picker', - properties: { object: 'account', filter: RULES }, + properties: { labelField: 'name' }, dataSource: { object: 'account', filter: RULES }, }); expect(binding.success).toBe(true); const bindingRecord = PageComponentSchema.safeParse({ type: 'element:record_picker', - properties: { object: 'account', filter: RULES }, + properties: { labelField: 'name' }, dataSource: { object: 'account', filter: RECORD_FORM }, }); expect(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)).toEqual(['invalid_type']); - const picker = ComponentPropsMap['element:record_picker']; - expect(issuesAt(picker.safeParse({ object: 'account', filter: RULES }), 'filter')).toEqual([]); - expect(issuesAt(picker.safeParse({ object: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code)) + const grid = ComponentPropsMap['object-grid']; + expect(issuesAt(grid.safeParse({ objectName: 'account', filter: RULES }), 'filter')).toEqual([]); + expect(issuesAt(grid.safeParse({ objectName: 'account', filter: RECORD_FORM }), 'filter').map((i) => i.code)) .toEqual(issuesAt(bindingRecord, 'dataSource.filter').map((i) => i.code)); }); }); @@ -806,7 +808,7 @@ describe('PageComponent dataSource integration', () => { it('should accept component with dataSource', () => { const component = PageComponentSchema.parse({ type: 'element:number', - properties: { object: 'order', aggregate: 'sum', field: 'total' }, + properties: { aggregate: 'sum', field: 'total' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }], @@ -867,9 +869,10 @@ describe('PageVariableSchema record_id type', () => { type: 'element:record_picker', // The binding is carried by the VARIABLE's `source` above, not by // any picker prop — `displayField` (#5775) and `targetVariable` - // (#9198) are both retired. + // (#9198) are both retired. Its object is the node-level binding + // (the flat `object` retired in v18, #11509). + dataSource: { object: 'account' }, properties: { - object: 'account', labelField: 'name', }, }, @@ -904,12 +907,12 @@ describe('Page end-to-end', () => { }, { type: 'element:number', - properties: { object: 'order', aggregate: 'count' }, + properties: { aggregate: 'count' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'pending' }] }, }, { type: 'element:number', - properties: { object: 'order', aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, + properties: { aggregate: 'sum', field: 'total', format: 'currency', prefix: '$' }, dataSource: { object: 'order', filter: [{ field: 'status', operator: 'equals', value: 'completed' }] }, }, { From c70da2d652299c6e694edad5cef012404e72a7ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:27:44 +0000 Subject: [PATCH 08/14] wip(spec): ledger totals and the i18n fixture follow the retirement Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- packages/spec/src/system/i18n-resolver.test.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index b7924a1675d..7b3f8d546b6 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 220, - "droppedRefinementSites": 679, + "publishedSchemasWithDroppedRefinements": 217, + "droppedRefinementSites": 676, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index c812594ce6a..5bb2246b8d5 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1658,7 +1658,8 @@ describe('translatePage', () => { const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' }); expect(byId(out, 'quick_create').properties.icon).toBe('plus'); expect(byId(out, 'kpi_revenue_won').properties.value).toBe(42); - expect(byId(out, 'lead_picker').properties.object).toBe('lead'); + // The picker's object is its node-level binding since v18 (#11509) — kept too. + expect(byId(out, 'lead_picker').dataSource.object).toBe('lead'); }); it('leaves a component with no entry — and one with no id — untouched', () => { From fbb45ba6bc719664554fbf2069f3ccf412f0ee1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:37:41 +0000 Subject: [PATCH 09/14] =?UTF-8?q?wip(spec):=20api-surface=20and=20export-o?= =?UTF-8?q?rigins=20back=20to=20base=20=E2=80=94=20no=20export=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/api-surface/ui.json | 2 -- packages/spec/export-origins/ui.json | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 9de1a5bce4b..0b28904473a 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -353,7 +353,6 @@ "RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES (const)", - "RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef (interface)", "ReactInteractionProp (interface)", @@ -401,7 +400,6 @@ "ResolvedActionParam (interface)", "ResponsiveStyles (type)", "ResponsiveStylesSchema (const)", - "RetiredFlatBindingElementType (type)", "RowColorConfig (type)", "RowColorConfigSchema (const)", "RowHeight (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 4006e862ac9..26e94e5a860 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -347,7 +347,6 @@ "RECORD_CONTEXT_BLOCK_TAGS": "src/ui/react-blocks.ts#RECORD_CONTEXT_BLOCK_TAGS (const)", "RECORD_CONTEXT_TYPE_PREFIX": "src/ui/react-blocks.ts#RECORD_CONTEXT_TYPE_PREFIX (const)", "RESERVED_COMPONENT_TYPE_NAMESPACES": "src/ui/component-type-vocabulary.ts#RESERVED_COMPONENT_TYPE_NAMESPACES (const)", - "RETIRED_ELEMENT_FLAT_BINDING_KEYS": "src/ui/component.zod.ts#RETIRED_ELEMENT_FLAT_BINDING_KEYS (const)", "RETIRED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#RETIRED_PAGE_COMPONENT_TYPES (const)", "ReactBlockDef": "src/ui/react-blocks.ts#ReactBlockDef (interface)", "ReactInteractionProp": "src/ui/react-blocks.ts#ReactInteractionProp (interface)", @@ -386,7 +385,6 @@ "ResolvedActionParam": "src/ui/action-params.zod.ts#ResolvedActionParam (interface)", "ResponsiveStyles": "src/ui/responsive.zod.ts#ResponsiveStyles (type)", "ResponsiveStylesSchema": "src/ui/responsive.zod.ts#ResponsiveStylesSchema (const)", - "RetiredFlatBindingElementType": "src/ui/component.zod.ts#RetiredFlatBindingElementType (type)", "RowColorConfig": "src/ui/view.zod.ts#RowColorConfig (type)", "RowColorConfigSchema": "src/ui/view.zod.ts#RowColorConfigSchema (const)", "RowHeight": "src/ui/view.zod.ts#RowHeight (type)", From c0584368e2c459fd14b8e73ce57f6669a77a6674 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 04:01:13 +0000 Subject: [PATCH 10/14] chore(spec): regenerate the ui/component reference after the merge Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 6c3eb738424..30e912774a0 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -341,10 +341,10 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:number` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **field** | `string` | optional | Field to aggregate | | **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max'>` | ✅ | Aggregation function | -| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:number` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the element now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource.filter` already has rules, append these to it, since the two always AND-combined. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **format** | `Enum<'number' \| 'currency' \| 'percent'>` | optional | Number display format | | **prefix** | `string` | optional | Prefix text (e.g. "$") | | **suffix** | `string` | optional | Suffix text (e.g. "%") | @@ -367,13 +367,13 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:record_picker` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.object`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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` | optional | Control label rendered above the select | -| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:record_picker` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.filter`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:record_picker` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.sort`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:record_picker` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a flat second spelling of the node-level `dataSource.limit`, and the picker now reads its query from `dataSource` only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it, delete this one, since the binding's value always won. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **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: '', type: 'record_id', source: '' }]`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **placeholder** | `string \| Record` | optional | Placeholder text | | **emptyText** | `string \| Record` | optional | Text shown when the query returns no records (default "No records") | @@ -399,12 +399,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 18 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | @@ -797,7 +797,7 @@ Sort field and direction pair | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns — all field-name strings, or all column entries `{ field, label?, width?, align?, hidden?, sortable?, … }`, the same union a list view's `columns` declares. One spelling per list: an array mixing strings and column objects is refused | | **fields** | `string[]` | optional | Field-name fallback the grid reads when `columns` is absent — bare field names (`['name', 'amount']`); write column decoration such as `label` or `width` on `columns` | | **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` | -| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **defaultFilters** | `never` | optional | [REMOVED] `object-grid` property `defaultFilters` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was the legacy second spelling of `filter`: the same rules, read only when `filter` lowered to nothing, so one intent had two spellings and a grid authoring both silently ignored this one. Use `filter`. Rename the key where `filter` is empty; the value (a ViewFilterRule array, `[{ field, operator, value }, ...]`) is unchanged. Where `filter` already has rules, delete this key: the grid never read it there. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **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; `--write` applies the ones it can prove, and you apply the rest by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | From ebf82d0324eda4bf46d9d1f7bfe5001fc70f9a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:59:59 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(spec):=20repeater=20prose=20holds=20a?= =?UTF-8?q?t=20the=20new=20pin=20=E2=80=94=20it=20reads=20the=20binding=20?= =?UTF-8?q?first=20and=20keeps=20its=20flat=20keys=20as=20a=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/conversions/registry.ts | 33 +++++++++++-------- .../18.element-flat-data-binding-retired.ts | 4 +-- .../element-flat-binding-retirement.test.ts | 5 ++- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index b819c5d0c60..0736edfc1f6 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7329,7 +7329,10 @@ function isRuleObjectArray(value: unknown): value is Dict[] { * the saved view it names) first, the flat key second * (`composed?. ?? props.`); * - `element:number`'s `filter`: AND-combined with the binding's; - * - `element:repeater`: the flat keys ONLY — the binding was not read at all. + * - `element:repeater`: the flat keys ONLY — the binding was not read at all + * (objectui#11880 then put the binding first, keeping the flat keys as a + * fallback, so a value the two disagree on was applied differently by the + * two console versions). */ type FlatBindingDisposition = | { kind: 'move' } @@ -7352,17 +7355,18 @@ function flatBindingDisposition( return { kind: 'todo', reason: `\`dataSource.${key}\` is set to a different value than this flat \`${key}\`. The list read ` - + 'only its flat keys until it moved onto the binding, so the binding\'s value never applied; now it ' - + `is the only one read. Keep the value you mean in \`dataSource.${key}\` and delete this key.`, + + 'only its flat keys until the console moved it onto the binding, and reads the binding first since, ' + + 'so which of the two it applied depends on the console version. Keep the value you mean in ' + + `\`dataSource.${key}\` and delete this key.`, }; } if (view !== undefined && key !== 'object') { return { kind: 'todo', - reason: `the binding names the saved view \`${view}\`, which the list did not read until it moved onto ` - + `the binding. Moved there, this \`${key}\` would combine with the view's own (a filter ANDs, a ` - + 'sort or a limit overrides it), which the list never did. Decide whether the list should apply the ' - + `view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, + reason: `the binding names the saved view \`${view}\`, which the list did not read until the console ` + + `moved it onto the binding. On the binding, this \`${key}\` combines with the view's own (a filter ` + + 'ANDs, a sort or a limit overrides it), which an older console never applied. Decide whether the list ' + + `should apply the view, then write the \`${key}\` you mean on \`dataSource\` and delete this key.`, }; } return { kind: 'move' }; @@ -7406,9 +7410,10 @@ function flatBindingDisposition( * `element:repeater` `object` / `filter` / `sort` / `limit`. Each was the * same query as a key of `ElementDataSourceSchema`, resolved per renderer by * three different rules, and objectui#11880 (objectui `5bc55c0c5a1e`) moved - * all three renderers onto the binding alone — the order the ruling set, so - * this rewrite never moves a working list's query into a position its - * renderer does not read. + * all three renderers onto the binding — the picker and `element:number` read + * it alone, the repeater reads it first and keeps its flat keys as a fallback + * — in the order the ruling set, so this rewrite never moves a working list's + * query into a position its renderer does not read. * * Mechanical where the OLD rule decides the answer * ({@link flatBindingDisposition}): @@ -7425,8 +7430,9 @@ function flatBindingDisposition( * key the binding lacks beside a `dataSource.view` (whether the view's own key * displaced it depends on the view, which no conversion reads); a repeater key * the binding sets to a different value, or beside a `view` (the repeater read - * neither before, so moving it would combine it with what the list never - * applied); and an `element:number` filter pair that is not two rule arrays. A + * neither before objectui#11880 and reads the binding first since, so what it + * applied depends on the console version); and an `element:number` filter pair + * that is not two rule arrays. A * key left as stored no longer reaches a query, and its tombstone refuses it * at the next parse with the same prescription. * @@ -7590,7 +7596,8 @@ const elementFlatDataBindingToDataSource: MetadataConversion = { }, ], }, - // The named-slot shape: a repeater, which read its flat keys alone. + // The named-slot shape: a repeater, which read its flat keys alone + // before the console put its binding first. { name: 'deal_detail', kind: 'slotted', diff --git a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts index 134f7ead282..ddebd52501b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-flat-data-binding-retired.ts @@ -39,8 +39,8 @@ export const entry: SemanticMigration = { + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' - + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' - + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console ' + + 'put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not ' + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' diff --git a/packages/spec/src/ui/element-flat-binding-retirement.test.ts b/packages/spec/src/ui/element-flat-binding-retirement.test.ts index 2e43a2066f9..8bc46e60f32 100644 --- a/packages/spec/src/ui/element-flat-binding-retirement.test.ts +++ b/packages/spec/src/ui/element-flat-binding-retirement.test.ts @@ -136,7 +136,10 @@ describe('the element tombstones — refused at the key, with the prescription', expect(message('element:record_picker', 'limit')).toContain('delete this one, since the binding\'s value always won'); expect(message('element:number', 'object')).toContain('delete this one, since the binding\'s value always won'); expect(message('element:number', 'filter')).toContain('append these to it, since the two always AND-combined'); - expect(message('element:repeater', 'sort')).toContain('keep the value written here, which is the one the list honoured'); + // The repeater put the binding first only at objectui#11880 and keeps its flat keys as a + // fallback, so two console versions applied a disagreeing pair differently: no rule to state. + expect(message('element:repeater', 'sort')).toContain('decide which of the two values the list should use'); + expect(message('element:repeater', 'sort')).not.toContain('reaches no query'); }); it('refuses by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { From 5f93f6bfc07197e6c85ad7cef5cf21cc24cff300 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:00:37 +0000 Subject: [PATCH 12/14] chore(spec): regenerate the migration registry after the merge Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index a9691dd8d08..8c99c11053c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11578,8 +11578,8 @@ const step18: MigrationStep = { + 'left as stored and listed as TODOs, because only the author can decide them: a record-picker ' + 'key beside a `dataSource.view` the binding sets no such key of its own for (the flat value ' + 'applied only if the view supplied none, and no conversion reads the view); a repeater key the ' - + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater never read either, so the ' - + 'list now applies something it did not before); and an `element:number` filter pair that is not ' + + 'binding sets to a DIFFERENT value, or beside a `view` (the repeater read neither until the console ' + + 'put its binding first, so what it applied depends on the console version); and an `element:number` filter pair that is not ' + 'two rule arrays. A repeater that carried a `dataSource` its list ignored now applies it — ' + 'compare it with what the list showed. A flat filter in the retired record form moves to ' + '`dataSource.filter` and is then converted there by `page-component-filter-record-to-rule-array` ' From 756fa0b335cc88db527ffee55543a81aaec39708 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:09:05 +0000 Subject: [PATCH 13/14] chore(spec): regenerate the ui/component reference for the repeater wording Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 30e912774a0..4fd631069bc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -399,12 +399,12 @@ const result = ActionButtonPropsSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **object** | `never` | optional | [REMOVED] `element:repeater` property `object` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.object` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (an object name) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **titleField** | `string` | optional | Field shown first on each line, emphasized | | **fields** | `(string \| { field: string })[]` | optional | Fields shown after the title on each line, in order — a bare field name, or `{ field }` | -| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — the list now reads its query from the node-level `dataSource` binding only, so a value written here reaches no query. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, keep the value written here, which is the one the list honoured. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **filter** | `never` | optional | [REMOVED] `element:repeater` property `filter` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.filter` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a ViewFilterRule array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **sort** | `never` | optional | [REMOVED] `element:repeater` property `sort` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.sort` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a `[{ field, order }]` array) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **limit** | `never` | optional | [REMOVED] `element:repeater` property `limit` was removed in @objectstack/spec 17 (ADR-0087 D2) — it was a second door onto the list's query, which the list reads from the node-level `dataSource` binding first, keeping this key only as a fallback for metadata written before it. Use `dataSource.limit` on the component node, a sibling of `type` rather than a key inside `properties`. Move the key; the value (a positive integer) is unchanged, and where `dataSource` already sets it too, decide which of the two values the list should use and keep that one on `dataSource`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **emptyText** | `string` | optional | Copy shown when the query returns no records (renderer default: "No records"). A literal string — localize through the translation bundle entry for this component id | | **divided** | `boolean` | optional | Draw a separator between lines (renderer default: true) | From c1d688804570706912833f73b081fd29af83e54d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:18:01 +0000 Subject: [PATCH 14/14] test(cli): the migrate-meta guidance pin stops naming the three step-18 entries this retirement absorbs The REWRITTEN floor in migrate-meta-engine-guidance.test.ts still named element-number-filter-rule-array, element-record-picker-filter-rule-array and object-grid-default-filters-rule-array. The element flat binding retirement absorbs all three into its own D3 entries, so none of them is in MIGRATIONS_BY_MAJOR any more and the floor case read "family lost element-number-filter-rule-array". The list's own rule admits only entries rewritten when their family was brought to the no-tracker-id line; an entry born without a tracker id needs no row, because the whole directory is held by the printed-block case. The two absorbing entries (element-flat-data-binding-retired, object-grid-default-filters-retired) were born that way, so they are not added. The assertion is unchanged. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/cli/test/migrate-meta-engine-guidance.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/test/migrate-meta-engine-guidance.test.ts b/packages/cli/test/migrate-meta-engine-guidance.test.ts index ddc6aea620a..a34e992ac1e 100644 --- a/packages/cli/test/migrate-meta-engine-guidance.test.ts +++ b/packages/cli/test/migrate-meta-engine-guidance.test.ts @@ -155,8 +155,6 @@ const REWRITTEN = [ 'driver-sql-upsert-cross-row-identity-merge-refused', 'driver-turso-config-local-path-wasm-retired', 'element-data-source-and-object-block-filter-rule-array', - 'element-number-filter-rule-array', - 'element-record-picker-filter-rule-array', 'engine-dotted-filter-refused', 'engine-dotted-projection-refused', 'engine-find-formula-filter-refused', @@ -227,7 +225,6 @@ const REWRITTEN = [ 'notification-list-cursor-retired', 'object-block-sort-item-array', 'object-grid-data-view-data-converged', - 'object-grid-default-filters-rule-array', 'object-index-unknown-keys-refused', 'observability-cel-predicates-retired', 'package-api-contracts-unmounted-entries-retired',