From e0fdfb5647ed51ed65d51e0bc6b5aec454379e71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:29:05 +0000 Subject: [PATCH 1/2] feat(types)!: retire FilterUISchema.filters[].operator on the filter-ui node filter-ui never read a per-filter operator: the renderer picks the control from `type` and emits `{ values }` only, with no matching of its own. The key is now a `?: never` tombstone on the TS face and a retirementTombstone() on the zod mirror, refused by name at the entry's own path. The docs Schema block drops it and states the retirement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- .../10611-filter-ui-operator-retired.md | 35 ++++ content/docs/components/complex/filter-ui.mdx | 9 +- .../filter-ui-operator-retired-10611.test.ts | 151 ++++++++++++++++++ packages/types/src/views.ts | 18 ++- packages/types/src/zod/views.zod.ts | 8 +- 5 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 .changeset/10611-filter-ui-operator-retired.md create mode 100644 packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts diff --git a/.changeset/10611-filter-ui-operator-retired.md b/.changeset/10611-filter-ui-operator-retired.md new file mode 100644 index 0000000000..d4ec663f95 --- /dev/null +++ b/.changeset/10611-filter-ui-operator-retired.md @@ -0,0 +1,35 @@ +--- +'@object-ui/types': minor +--- + +feat(types)!: `FilterUISchema.filters[].operator` is retired on the `filter-ui` node and refused by name + +⚠️ Breaking, marked `minor` under this repo's version-alignment rule (a `major` +in the fixed group would move all of it off the `@objectstack` major). A +`filter-ui` node whose `filters[]` entry authors `operator` now FAILS to +validate, and a TypeScript literal typed as `FilterUISchema` that sets it no +longer compiles. + +`filter-ui` never read a per-filter operator. Its renderer picks each control +from `type` and emits `{ values }` only (to a host `onChange` function and to +the authored window event), a field → value record with no operator in it, and +it does no matching of its own. Yet both published faces declared the key (the +mirror as a seven-member enum) and the docs page taught it, so +`operator: 'gt'`, or a nonsense id, type-checked, parsed green through +`objectui validate`, and changed nothing the component rendered or emitted. + +The member is now a `?: never` tombstone on the TypeScript face and a +`retirementTombstone()` on the zod mirror (ADR-0049). The `filters[]` entry is +a plain object schema that strips an undeclared key, so deleting the +declaration would have dropped an authored value in silence rather than refused +it. The refusal is an `invalid_type` issue at the entry's own path +(`filters.N.operator`). No replacement key is named, because nothing in this +component implements operators: remove the key. How each value is matched is up +to the host that consumes the change. + +The `filter-ui` docs page no longer lists `operator` in its Schema block and +states the retirement. A one-time census, recorded on the card's pull request +and not re-derived here, found no producer that writes the key and no reader +that honours it. + +Pinned in `packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts`. diff --git a/content/docs/components/complex/filter-ui.mdx b/content/docs/components/complex/filter-ui.mdx index 0682ffc86e..d5f6d8f4bf 100644 --- a/content/docs/components/complex/filter-ui.mdx +++ b/content/docs/components/complex/filter-ui.mdx @@ -37,7 +37,6 @@ interface FilterUISchema { field: string; label?: string; type: 'text' | 'number' | 'select' | 'date' | 'date-range' | 'boolean'; - operator?: 'equals' | 'contains' | 'startsWith' | 'gt' | 'lt' | 'between' | 'in'; options?: Array<{ label: string; value: any }>; placeholder?: string; }>; @@ -48,3 +47,11 @@ interface FilterUISchema { layout?: 'inline' | 'popover' | 'drawer'; } ``` + +### Retired: `filters[].operator` + +A filter entry no longer takes an `operator` (objectui#10611). The component never +read one: it picks each control from `type` and reports changes as `{ values }`, a +field-to-value record with no operator in it, and it does no matching of its own. How +each value is matched is up to the host that consumes the change. An authored +`operator` is now refused by name when the schema is validated. diff --git a/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts b/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts new file mode 100644 index 0000000000..44c7cd396d --- /dev/null +++ b/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts @@ -0,0 +1,151 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — `FilterUISchema.filters[].operator` is REFUSED on the + * `filter-ui` node (objectui#10611, ADR-0049 enforce-or-remove). + * + * ## The failure this pin exists to prevent + * + * Both published faces declared a per-filter `operator` (a seven-member enum on + * the mirror) and the docs page taught it, but `filter-ui`'s renderer never + * read it: it picks each control from `type` and emits `{ values }` only, a + * field → value record with no operator, and does no matching of its own. So + * `operator: 'gt'` — or a nonsense id — type-checked, parsed green through + * `objectui validate`, and changed nothing the component rendered or emitted. + * The render reading and the producer census are recorded on the card's pull + * request as a one-time measurement; this file is the instrument that keeps the + * contract. + * + * ## Why a tombstone and not a deletion + * + * `operator` sits on the `filters[]` ELEMENT, a plain `z.object`, which STRIPS + * an undeclared key (it is not the node's `.passthrough()` — that one KEEPS + * it). Either way a deleted member would have been a silent accept; block (c) + * pins the element's strip on a misspelling, so the reason is a reading and not + * prose. + * + * ## How refusals are asserted + * + * By the issue ENVELOPE — `code` and `path`, and that it is the ONLY issue, so a + * refusal cannot ride on some other failure of the same document — at the public + * door `safeValidateSchema` (what `objectui validate` / `objectui check` run) and + * on the mirror. The wording is not pinned; that the message and the published + * `.describe()` metadata are one string is. + * + * The `@ts-expect-error` directive in block (d) is REAL enforcement: this + * package type-checks its tests through `tsconfig.test.json`, so re-widening the + * member fails the build on the unused directive (TS2578). A green `vitest` run + * is NOT evidence about it — type assertions are erased before it runs. + */ + +import { describe, it, expect } from 'vitest'; +import type { FilterUISchema } from '../views'; +import { FilterUISchema as FilterUIMirror } from '../zod/views.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +type FilterEntry = FilterUISchema['filters'][number]; + +const QTY = { field: 'qty', label: 'Qty', type: 'number' } as const; +const NAME = { field: 'name', label: 'Name', type: 'text' } as const; +const node = (filters: Array>) => ({ type: 'filter-ui', layout: 'inline', filters }); + +/** + * Every member of the retired enum (so a partial re-widening shows up), plus a + * spelling the enum never admitted and a nonsense id — the render reading gave + * all of them the same output as no operator at all. + */ +const AUTHORED = ['equals', 'contains', 'startsWith', 'gt', 'lt', 'between', 'in', 'greater_than', 'zz_nonsense_op']; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +const issuesOf = (r: { success: boolean; error?: { issues: Issue[] } }): Issue[] => r.error?.issues ?? []; + +const elementShape = ( + (FilterUIMirror.shape.filters as unknown as { element: { shape: Record } }).element +).shape; + +/* ── (a) refused by name, at the entry's own path ─────────────────────────── */ + +describe('objectui#10611 (a) — `filters[].operator` is RETIRED on `filter-ui`', () => { + it.each(AUTHORED.map((op) => [op]))( + 'refuses `operator: %s` at the public door and on the mirror: ONE `invalid_type` issue at the entry', + (op) => { + const doc = node([QTY, { ...NAME, operator: op }]); + for (const r of [safeValidateSchema(doc), FilterUIMirror.safeParse(doc)]) { + expect(r.success, `an authored \`operator: ${op}\` was ACCEPTED`).toBe(false); + expect(issuesOf(r).map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'invalid_type', path: ['filters', 1, 'operator'] }, + ]); + } + }, + ); + + it('the refusal message and the published `.describe()` metadata are ONE string', () => { + const r = FilterUIMirror.safeParse(node([{ ...QTY, operator: 'gt' }])); + expect(issuesOf(r)[0]!.message).toBe(elementShape.operator!.description); + }); + + it('stays DECLARED on the entry shape — a tombstone, not a deletion (see block c)', () => { + expect(Object.keys(elementShape)).toContain('operator'); + }); +}); + +/* ── (b) LIT CONTROL: the same entries without `operator` parse ───────────── */ + +describe('objectui#10611 (b) — LIT CONTROL: a `filters[]` entry without `operator` parses, at both doors', () => { + it('the same two entries, no operator', () => { + const doc = node([QTY, NAME]); + expect(issuesOf(safeValidateSchema(doc))).toEqual([]); + expect(issuesOf(FilterUIMirror.safeParse(doc))).toEqual([]); + }); + + it('every surviving entry member, on one entry', () => { + const doc = node([ + { field: 'status', label: 'Status', type: 'select', placeholder: 'Any', options: [{ label: 'Open', value: 'open' }] }, + ]); + expect(issuesOf(safeValidateSchema(doc))).toEqual([]); + }); +}); + +/* ── (c) why a tombstone: an undeclared entry key is STRIPPED in silence ──── */ + +describe('objectui#10611 (c) — CONTROL: an undeclared `filters[]` key is STRIPPED, not refused', () => { + it('a misspelled `operatr` parses green and is gone after the parse — what a deletion would have left', () => { + const r = FilterUIMirror.safeParse(node([{ ...QTY, operatr: 'gt' }])); + expect(r.success).toBe(true); + expect(Object.keys((r.data as { filters: Array> }).filters[0]!)).not.toContain('operatr'); + }); +}); + +/* ── (d) the TS twin carries the same contract ───────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +export type assertionOperatorRetired = Expect>; +/** The helper can FAIL — control on a surviving optional member of the same entry. */ +export type assertionEqualCanFail = Expect, false>>; + +describe('objectui#10611 (d) — the TS twin refuses what the mirror refuses', () => { + it('an authored `operator` is a compile error — checked by `tsc -p tsconfig.test.json`', () => { + // Not `as const`: a readonly literal would fail for a reason of its own, and + // the `@ts-expect-error` would pass without testing. + const refused: FilterUISchema = { + type: 'filter-ui', + // @ts-expect-error — retired: `filter-ui` does not read a per-filter operator + filters: [{ field: 'qty', type: 'number', operator: 'gt' }], + }; + expect(refused.filters).toHaveLength(1); + }); + + it('CONTROL — the same entry without `operator` compiles', () => { + const node: FilterUISchema = { type: 'filter-ui', filters: [{ field: 'qty', type: 'number' }] }; + expect(node.filters[0]!.type).toBe('number'); + }); +}); diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 2088cccd84..3a0237a738 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -1134,9 +1134,23 @@ export interface FilterUISchema extends BaseSchema { */ type: 'text' | 'number' | 'select' | 'multi-select' | 'date' | 'date-range' | 'boolean'; /** - * Filter operator + * RETIRED (objectui#10611, ADR-0049) — `filter-ui` never reads a per-filter + * operator. Its renderer (`FilterUI` in `@object-ui/plugin-view`) picks the + * control from `type` and emits `{ values }` only: a field → value record + * with no operator in it. It does no matching of its own, so how each value + * is matched is up to the host that consumes the change. + * + * Until this retirement the key was declared here, mirrored in zod with a + * seven-member enum, and taught by the docs page — yet an authored + * `operator: 'gt'`, or a nonsense id, rendered and emitted exactly what a + * filter without it does. Nothing in this component implements operators, + * so no replacement key is named: remove it. `?: never` here and a + * `retirementTombstone()` on the mirror refuse it by name; deleting the + * member would have let the mirror strip an authored value in silence. + * + * @deprecated Retired — `filter-ui` does not read it. */ - operator?: 'equals' | 'contains' | 'startsWith' | 'gt' | 'lt' | 'between' | 'in'; + operator?: never; /** * Options for select filter */ diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 5caaadb66a..9b06cc60f1 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -359,7 +359,13 @@ export const FilterUISchema = BaseSchema.extend({ field: z.string().describe('Filter field'), label: z.string().optional().describe('Filter label'), type: z.enum(['text', 'number', 'select', 'multi-select', 'date', 'date-range', 'boolean']).describe('Filter type'), - operator: z.enum(['equals', 'contains', 'startsWith', 'gt', 'lt', 'between', 'in']).optional().describe('Filter operator'), + operator: retirementTombstone( + 'RETIRED (objectui#10611, ADR-0049) — `filter-ui` never reads a per-filter `operator`: the renderer ' + + 'picks the control from `type` and emits `{ values }` only, a field → value record with no operator ' + + 'in it, and does no matching of its own. An authored value changed nothing the component rendered ' + + 'or emitted. Nothing in this component implements operators, so remove the key; how each value is ' + + 'matched is up to the host that consumes the change.', + ), options: z.array(z.object({ label: z.string(), value: z.any() })).optional().describe('Options for select filter'), placeholder: z.string().optional().describe('Placeholder'), })).describe('Available filters'), From 8eb899fd9d9a7615495434624b7a570ebf66c253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 07:33:02 +0000 Subject: [PATCH 2/2] docs(types): restate the filter-ui operator retirement's before-state truthfully The changeset and the pin header said an authored `operator: 'gt'`, "or a nonsense id", type-checked and parsed green before the retirement. That is false for the nonsense id: the old mirror was a seven-member z.enum and the old TS member the same union, so a non-member was refused (invalid_value) and did not type-check. Both now say that 'gt' type-checked and parsed green, a nonsense id was refused by the enum, and both rendered and emitted exactly what a filter without it does. The emit sentence is made exact too: a host onChange function receives the bare field -> value record; only the authored window event's detail is { values }. The AUTHORED docblock no longer credits the one-time render reading with values it did not sample. Prose and comments only; no code or test logic changes, and the changeset front matter is byte-identical. Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW Co-authored-by: Claude --- .../10611-filter-ui-operator-retired.md | 15 +++++++++------ .../filter-ui-operator-retired-10611.test.ts | 19 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.changeset/10611-filter-ui-operator-retired.md b/.changeset/10611-filter-ui-operator-retired.md index d4ec663f95..151ac2fbbe 100644 --- a/.changeset/10611-filter-ui-operator-retired.md +++ b/.changeset/10611-filter-ui-operator-retired.md @@ -11,12 +11,15 @@ validate, and a TypeScript literal typed as `FilterUISchema` that sets it no longer compiles. `filter-ui` never read a per-filter operator. Its renderer picks each control -from `type` and emits `{ values }` only (to a host `onChange` function and to -the authored window event), a field → value record with no operator in it, and -it does no matching of its own. Yet both published faces declared the key (the -mirror as a seven-member enum) and the docs page taught it, so -`operator: 'gt'`, or a nonsense id, type-checked, parsed green through -`objectui validate`, and changed nothing the component rendered or emitted. +from `type` and reports a change as a field → value record with no operator in +it: a host `onChange` function receives that bare record, and only the authored +window event wraps it, as `detail: { values }`. It does no matching of its own. +Yet both published faces declared the key (the mirror as a seven-member enum, +the TypeScript face as the same union) and the docs page taught it. So +`operator: 'gt'`, a member of that enum, type-checked and parsed green through +`objectui validate`; a nonsense id was refused by the enum and did not +type-check; and both rendered and emitted exactly what a filter without it +does. The member is now a `?: never` tombstone on the TypeScript face and a `retirementTombstone()` on the zod mirror (ADR-0049). The `filters[]` entry is diff --git a/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts b/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts index 44c7cd396d..589589b014 100644 --- a/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts +++ b/packages/types/src/__tests__/filter-ui-operator-retired-10611.test.ts @@ -13,11 +13,15 @@ * ## The failure this pin exists to prevent * * Both published faces declared a per-filter `operator` (a seven-member enum on - * the mirror) and the docs page taught it, but `filter-ui`'s renderer never - * read it: it picks each control from `type` and emits `{ values }` only, a - * field → value record with no operator, and does no matching of its own. So - * `operator: 'gt'` — or a nonsense id — type-checked, parsed green through - * `objectui validate`, and changed nothing the component rendered or emitted. + * the mirror, the same union on the TS face) and the docs page taught it, but + * `filter-ui`'s renderer never read it: it picks each control from `type` and + * reports a change as a field → value record with no operator in it (a host + * `onChange` function receives that bare record; only the authored window + * event's `detail` is `{ values }`), and it does no matching of its own. So + * `operator: 'gt'`, a member of that enum, type-checked and parsed green + * through `objectui validate`; a nonsense id was refused by the enum and did + * not type-check; and both rendered and emitted exactly what a filter without + * it does. * The render reading and the producer census are recorded on the card's pull * request as a one-time measurement; this file is the instrument that keeps the * contract. @@ -57,8 +61,9 @@ const node = (filters: Array>) => ({ type: 'filter-ui', /** * Every member of the retired enum (so a partial re-widening shows up), plus a - * spelling the enum never admitted and a nonsense id — the render reading gave - * all of them the same output as no operator at all. + * spelling the enum never admitted and a nonsense id. The one-time render + * reading on the pull request sampled a subset of these (it lists which) and + * gave each sampled one the same output as no operator at all. */ const AUTHORED = ['equals', 'contains', 'startsWith', 'gt', 'lt', 'between', 'in', 'greater_than', 'zz_nonsense_op'];