diff --git a/.changeset/20450-view-filter-operator-input-typed.md b/.changeset/20450-view-filter-operator-input-typed.md new file mode 100644 index 00000000000..cc35473e23e --- /dev/null +++ b/.changeset/20450-view-filter-operator-input-typed.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown` + +**BREAKING for TypeScript code that writes a view filter rule through a published type**: `ViewFilterRule`, and every carrier of it — `ListView.filter`, a view tab's `filter`, `InterfacePageConfig.filterBy`, and the related-list, record-picker and `object-*` block filter doors. A narrowing of a published TYPE, landing as `minor` (the bump level is not the carrier; this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no export changes. + +`operator` is a `z.preprocess` over the alias fold, and zod types a preprocess's input from its function's parameter. That parameter was `unknown`, so `ViewFilterRule['operator']` was `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule on every carrier, and was refused only when the schema parsed it. The input type is now `ViewFilterOperator`, the vocabulary the alias table's own contract says new producers emit, so an alias spelling or a non-string is refused by the compiler. + +What does not change: + +- **The runtime.** `ViewFilterRuleSchema` still folds every legacy spelling it folded before (`eq`, `gt`, `notIn`, `isNull`, …) to its canonical id, and still refuses a non-string at `operator` with the enum's own issue. Stored `sys_metadata` rows, YAML and JSON bodies and plain-JS producers that carry an alias parse exactly as before, and `os validate` answers as before. +- **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design. +- **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum. + +## FROM → TO + +| Wrote (TypeScript) | Write instead | +| --- | --- | +| `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` | +| `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` | +| `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` | +| `operator: someString` (a value typed `string`) | type the unvalidated rule `unknown` and `ViewFilterRuleSchema.safeParse` it, or fold it with `normalizeFilterOperator` and check it against `VIEW_FILTER_OPERATORS` first | + +The one-line fix: write the canonical id. Every alias maps to exactly one, and `VIEW_FILTER_OPERATOR_ALIASES` is that map; the rewritten rule selects the same rows, because the schema already folded the alias to that id. + +Clause-②: no (narrowing) + + diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts new file mode 100644 index 00000000000..0801f2b9270 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-operator-input-canonical.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A TYPE-surface narrowing registered here for the reason +// `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript +// declaration, so there is no stored source for a D2 conversion to rewrite, and +// the compiler error it produces names the canonical vocabulary but not which +// member an alias spelling maps to. This guide is the channel that carries that +// second half. Nothing at rest moves and the runtime accept set is unchanged. +export const entry: SemanticMigration = { + id: 'view-filter-rule-operator-input-canonical', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every ' + + 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the ' + + 'related-list, record-picker and object-* block filter doors)', + replacement: + 'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A ' + + 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps ' + + 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to ' + + 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, ' + + 'isNull to is_null, and the rest). A value that is not yet known to be an operator — ' + + 'read from storage, a URL or user input — is typed unknown and handed to ' + + 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the ' + + 'schema stays the judge', + reason: + 'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s ' + + 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a ' + + 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, ' + + 'compiled on every carrier and was refused only when the door parsed it. The typed input ' + + 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract ' + + 'says new producers emit. ' + + 'The RUNTIME does not move: the door still folds every spelling it folded before to ' + + 'canonical and still refuses a non-string with the enum\'s own ' + + 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS ' + + 'producer that carries an alias keep parsing exactly as before, and os validate answers ' + + 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: what narrows is only what TypeScript source may write. ' + + 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists ' + + 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ' + + 'ADR-0122.', + acceptanceCriteria: + 'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a ' + + 'non-string, naming the canonical vocabulary. Rewrite each alias to the id ' + + 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the ' + + 'door already folded it to that id — and for a value typed string that is really ' + + 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need ' + + 'no action: they load and parse as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f3d2c181696..bcc2fff9e5e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16902,6 +16902,53 @@ const step18: MigrationStep = { + 'query before this change, so re-check what it is supposed to show rather than assuming ' + 'any earlier result set.', }, + // A TYPE-surface narrowing registered here for the reason + // `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript + // declaration, so there is no stored source for a D2 conversion to rewrite, and + // the compiler error it produces names the canonical vocabulary but not which + // member an alias spelling maps to. This guide is the channel that carries that + // second half. Nothing at rest moves and the runtime accept set is unchanged. + { + id: 'view-filter-rule-operator-input-canonical', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every ' + + 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the ' + + 'related-list, record-picker and object-* block filter doors)', + replacement: + 'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A ' + + 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps ' + + 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to ' + + 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, ' + + 'isNull to is_null, and the rest). A value that is not yet known to be an operator — ' + + 'read from storage, a URL or user input — is typed unknown and handed to ' + + 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the ' + + 'schema stays the judge', + reason: + 'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s ' + + 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a ' + + 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, ' + + 'compiled on every carrier and was refused only when the door parsed it. The typed input ' + + 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract ' + + 'says new producers emit. ' + + 'The RUNTIME does not move: the door still folds every spelling it folded before to ' + + 'canonical and still refuses a non-string with the enum\'s own ' + + 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS ' + + 'producer that carries an alias keep parsing exactly as before, and os validate answers ' + + 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 ' + + 'conversion: what narrows is only what TypeScript source may write. ' + + 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists ' + + 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / ' + + 'ADR-0122.', + acceptanceCriteria: + 'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a ' + + 'non-string, naming the canonical vocabulary. Rewrite each alias to the id ' + + 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the ' + + 'door already folded it to that id — and for a value typed string that is really ' + + 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need ' + + 'no action: they load and parse as before.', + }, // The scalar half of the coupling #6227 declared and did not judge. Recorded // here rather than amended onto `view-filter-rule-value-shaped-by-operator` // because that entry's own prose states the OPPOSITE reading as accepted, and diff --git a/packages/spec/src/ui/view-filter-operator-input-typed.test.ts b/packages/spec/src/ui/view-filter-operator-input-typed.test.ts new file mode 100644 index 00000000000..f8f9447cf62 --- /dev/null +++ b/packages/spec/src/ui/view-filter-operator-input-typed.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two-half pin for `ViewFilterRule.operator`'s typed input. + * + * `operator` is a `z.preprocess` over the alias fold, and zod types a + * preprocess's INPUT from its function's parameter. While that parameter was + * `normalizeFilterOperator`'s `unknown`, `ViewFilterRule` (a `z.input`) typed + * `operator` as `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule + * on every carrier and was refused only at parse time. The typed input is now + * the canonical `ViewFilterOperator`, and the runtime fold is untouched. + * + * The two halves pin different facts, and neither implies the other: + * + * 1. COMPILE TIME: the type refuses an alias spelling and a non-string, on the + * rule and on its carriers. These `@ts-expect-error` lines are real checks + * only because `tsconfig.test.json` compiles this file and + * `check:test-typecheck` gives it no ledger entry: a directive that stops + * applying is a TS2578, and that is red. + * 2. RUN TIME: the door still folds every alias the table declares, so stored + * `sys_metadata` rows and plain-JS producers keep parsing, and it still + * refuses a non-string with the enum's own issue. `.parse` takes `unknown`, + * which is the deliberate escape: these fixtures exercise the fold, so they + * are never respelled to canonical to satisfy the type. + */ + +import { describe, expect, it } from 'vitest'; +import type { InterfacePageConfig } from './page.zod'; +import { + VIEW_FILTER_LIST_VALUE_OPERATORS, + VIEW_FILTER_OPERATORS, + VIEW_FILTER_OPERATOR_ALIASES, + VIEW_FILTER_PAIR_VALUE_OPERATORS, + ViewFilterRuleSchema, + type ListView, + type ViewFilterOperator, + type ViewFilterRule, + type ViewTab, +} from './view.zod'; + +type ListViewFilterRule = NonNullable[number]; +type TabFilterRule = NonNullable[number]; +type PageFilterRule = NonNullable[number]; + +// Lit control: the canonical spelling compiles on the rule and on each carrier, +// so the directives below fail on the OPERATOR, not on some other key. +const canonical: ViewFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnListView: ListViewFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnTab: TabFilterRule = { field: 'status', operator: 'equals', value: 'open' }; +const canonicalOnPage: PageFilterRule = { field: 'status', operator: 'equals', value: 'open' }; + +// @ts-expect-error an alias spelling is not the typed input (the runtime still folds it) +const alias: ViewFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error a non-string is not the typed input (the runtime refuses it) +const numeric: ViewFilterRule = { field: 'status', operator: 42, value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on ListView.filter +const aliasOnListView: ListViewFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: a non-string is refused on ListView.filter +const numericOnListView: ListViewFilterRule = { field: 'status', operator: 42, value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on a tab filter +const aliasOnTab: TabFilterRule = { field: 'status', operator: 'eq', value: 'open' }; +// @ts-expect-error the carrier inherits the rule's input: an alias is refused on Page.filterBy +const aliasOnPage: PageFilterRule = { field: 'status', operator: 'eq', value: 'open' }; + +/** The value shape the door's value check wants for a canonical operator. */ +function valueFor(operator: ViewFilterOperator): { value?: string | string[] } { + if ((VIEW_FILTER_LIST_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['open'] }; + if ((VIEW_FILTER_PAIR_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['a', 'z'] }; + return { value: 'open' }; +} + +describe('ViewFilterRule.operator: the typed input is canonical, the runtime fold is not narrowed', () => { + it('compiles the canonical spelling on the rule and its carriers, and the door accepts it', () => { + for (const rule of [canonical, canonicalOnListView, canonicalOnTab, canonicalOnPage]) { + expect(ViewFilterRuleSchema.parse(rule).operator).toBe('equals'); + } + }); + + it('folds an alias the type refuses to the canonical id the alias table names', () => { + const expected = VIEW_FILTER_OPERATOR_ALIASES.eq; + // The table's answer is itself a canonical member, so this is not vacuous. + expect((VIEW_FILTER_OPERATORS as readonly string[]).includes(expected ?? '')).toBe(true); + for (const rule of [alias, aliasOnListView, aliasOnTab, aliasOnPage]) { + expect(ViewFilterRuleSchema.parse(rule).operator).toBe(expected); + } + }); + + it('folds EVERY declared alias at run time', () => { + const entries = Object.entries(VIEW_FILTER_OPERATOR_ALIASES); + expect(entries.length).toBeGreaterThan(0); + for (const [spelling, target] of entries) { + const rule = { field: 'status', operator: spelling, ...valueFor(target) }; + expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target); + } + }); + + it('still reaches the case-folded branch of the fold (`GT`, `NotIn`)', () => { + // The fold lower-cases a spelling the table does not hold verbatim; the + // expected ids are read off the table under the lower-cased key. + const cases = [['GT', 'gt'], ['NotIn', 'notin']] as const; + for (const [spelling, key] of cases) { + const target = VIEW_FILTER_OPERATOR_ALIASES[key]; + expect(target, key).toBeDefined(); + const rule = { field: 'status', operator: spelling, ...valueFor(target as ViewFilterOperator) }; + expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target); + } + }); + + it("refuses a non-string the type refuses, with the enum's own issue at `operator`", () => { + for (const rule of [numeric, numericOnListView]) { + const result = ViewFilterRuleSchema.safeParse(rule); + expect(result.success).toBe(false); + const issues = result.error?.issues ?? []; + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue).toMatchObject({ code: 'invalid_value', path: ['operator'] }); + expect(issue && 'values' in issue ? issue.values : undefined).toEqual([...VIEW_FILTER_OPERATORS]); + } + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 77fbb237199..07956823b4d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -663,7 +663,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed) * * 1. **`z.discriminatedUnion` cannot read this discriminator — it does not - * construct.** `operator` is `z.preprocess(normalizeFilterOperator, z.enum(…))` + * construct.** `operator` is a `z.preprocess` over `normalizeFilterOperator` * — the alias fold that lets a stored `notIn` / `nin` / `gt` parse. Zod 4 * extracts a discriminator's literal values from the option's own def, and a * preprocess wrapper hides them: building the union throws @@ -857,6 +857,38 @@ function checkViewFilterRuleTextComparand( }); } +/** + * [#20450] The preprocess half of `ViewFilterRule.operator`. Its PARAMETER type + * is the rule's typed input. + * + * Zod types a `z.preprocess`'s INPUT from its function's parameter, so the + * parameter here is what `ViewFilterRule` (a `z.input`) and every carrier of it + * (`ListView.filter`, a tab filter, `Page.filterBy`, the component filter doors) + * publish for `operator`. It is the canonical {@link ViewFilterOperator}, because + * the alias table's own contract is that new producers emit canonical ids. + * Before this, the parameter was `normalizeFilterOperator`'s `unknown`, so + * `{ field: 'status', operator: 42 }` compiled as a rule on every carrier and was + * refused only at parse time. + * + * The RUNTIME is unchanged. The body hands whatever arrived to the exported + * {@link normalizeFilterOperator}, so a stored `sys_metadata` row or a plain-JS + * producer that carries an alias still parses and folds, and a non-string still + * reaches the enum and is refused there. The parameter type is therefore + * deliberately narrower than what this function receives: ⛔ never narrow its + * body on it. + * + * ⛔ Not an annotation on `normalizeFilterOperator` itself. That export exists + * so producers and renderers can fold UNTYPED stored metadata, and its callers + * pass raw strings and `unknown` by design: `@objectstack/lint`'s + * preset-comparand check and `@objectstack/rest`'s rule lowering pass a stored + * rule's `operator`, and the conversion registry folds an AST operator and the + * literal `'eq'`. Narrowing that parameter breaks them, or pushes a cast into + * each, to change a type only this schema publishes. + */ +function foldAuthoredViewFilterOperator(op: ViewFilterOperator): string { + return normalizeFilterOperator(op); +} + /** * View Filter Rule Schema * Standardized filter condition used in list views, tabs, and page-level filters. @@ -916,11 +948,14 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ /** Field name to filter on */ field: z.string().describe('Field name to filter on'), /** - * Filter operator (canonical vocabulary). Legacy shorthand/camelCase - * spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to - * canonical on parse. + * Filter operator (canonical vocabulary). The TYPED input is the canonical + * {@link ViewFilterOperator}: a typed author writing an alias or a + * non-string is refused at compile time. At RUNTIME the legacy + * shorthand/camelCase spellings (`eq`, `gt`, `isNull`, …) that stored + * metadata and plain-JS producers carry are still accepted and normalized + * to canonical on parse — see {@link foldAuthoredViewFilterOperator}. */ - operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)) + operator: z.preprocess(foldAuthoredViewFilterOperator, z.enum(VIEW_FILTER_OPERATORS)) .describe('Filter operator'), /** * Filter value (optional for unary operators like is_empty, is_null).