Skip to content

Commit 60102b7

Browse files
committed
feat(spec): type ViewFilterRule.operator's input as the canonical ViewFilterOperator
The preprocess on ViewFilterRuleSchema.operator now runs through a module-private wrapper whose parameter is ViewFilterOperator, so zod types the rule's input (and every carrier's) as the canonical vocabulary instead of unknown. The wrapper delegates to the unchanged exported fold, so stored rows and plain-JS producers that carry an alias still parse and fold. Adds the two-half pin beside it: @ts-expect-error on an alias and a number through ViewFilterRule and three carriers, and the runtime fold/refusal. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e02859 commit 60102b7

2 files changed

Lines changed: 160 additions & 5 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The two-half pin for `ViewFilterRule.operator`'s typed input.
5+
*
6+
* `operator` is a `z.preprocess` over the alias fold, and zod types a
7+
* preprocess's INPUT from its function's parameter. While that parameter was
8+
* `normalizeFilterOperator`'s `unknown`, `ViewFilterRule` (a `z.input`) typed
9+
* `operator` as `unknown`: `{ field: 'status', operator: 42 }` compiled as a rule
10+
* on every carrier and was refused only at parse time. The typed input is now
11+
* the canonical `ViewFilterOperator`, and the runtime fold is untouched.
12+
*
13+
* The two halves pin different facts, and neither implies the other:
14+
*
15+
* 1. COMPILE TIME: the type refuses an alias spelling and a non-string, on the
16+
* rule and on its carriers. These `@ts-expect-error` lines are real checks
17+
* only because `tsconfig.test.json` compiles this file and
18+
* `check:test-typecheck` gives it no ledger entry: a directive that stops
19+
* applying is a TS2578, and that is red.
20+
* 2. RUN TIME: the door still folds every alias the table declares, so stored
21+
* `sys_metadata` rows and plain-JS producers keep parsing, and it still
22+
* refuses a non-string with the enum's own issue. `.parse` takes `unknown`,
23+
* which is the deliberate escape: these fixtures exercise the fold, so they
24+
* are never respelled to canonical to satisfy the type.
25+
*/
26+
27+
import { describe, expect, it } from 'vitest';
28+
import type { InterfacePageConfig } from './page.zod';
29+
import {
30+
VIEW_FILTER_LIST_VALUE_OPERATORS,
31+
VIEW_FILTER_OPERATORS,
32+
VIEW_FILTER_OPERATOR_ALIASES,
33+
VIEW_FILTER_PAIR_VALUE_OPERATORS,
34+
ViewFilterRuleSchema,
35+
type ListView,
36+
type ViewFilterOperator,
37+
type ViewFilterRule,
38+
type ViewTab,
39+
} from './view.zod';
40+
41+
type ListViewFilterRule = NonNullable<ListView['filter']>[number];
42+
type TabFilterRule = NonNullable<ViewTab['filter']>[number];
43+
type PageFilterRule = NonNullable<InterfacePageConfig['filterBy']>[number];
44+
45+
// Lit control: the canonical spelling compiles on the rule and on each carrier,
46+
// so the directives below fail on the OPERATOR, not on some other key.
47+
const canonical: ViewFilterRule = { field: 'status', operator: 'equals', value: 'open' };
48+
const canonicalOnListView: ListViewFilterRule = { field: 'status', operator: 'equals', value: 'open' };
49+
const canonicalOnTab: TabFilterRule = { field: 'status', operator: 'equals', value: 'open' };
50+
const canonicalOnPage: PageFilterRule = { field: 'status', operator: 'equals', value: 'open' };
51+
52+
// @ts-expect-error an alias spelling is not the typed input (the runtime still folds it)
53+
const alias: ViewFilterRule = { field: 'status', operator: 'eq', value: 'open' };
54+
// @ts-expect-error a non-string is not the typed input (the runtime refuses it)
55+
const numeric: ViewFilterRule = { field: 'status', operator: 42, value: 'open' };
56+
// @ts-expect-error the carrier inherits the rule's input: an alias is refused on ListView.filter
57+
const aliasOnListView: ListViewFilterRule = { field: 'status', operator: 'eq', value: 'open' };
58+
// @ts-expect-error the carrier inherits the rule's input: a non-string is refused on ListView.filter
59+
const numericOnListView: ListViewFilterRule = { field: 'status', operator: 42, value: 'open' };
60+
// @ts-expect-error the carrier inherits the rule's input: an alias is refused on a tab filter
61+
const aliasOnTab: TabFilterRule = { field: 'status', operator: 'eq', value: 'open' };
62+
// @ts-expect-error the carrier inherits the rule's input: an alias is refused on Page.filterBy
63+
const aliasOnPage: PageFilterRule = { field: 'status', operator: 'eq', value: 'open' };
64+
65+
/** The value shape the door's value check wants for a canonical operator. */
66+
function valueFor(operator: ViewFilterOperator): { value?: string | string[] } {
67+
if ((VIEW_FILTER_LIST_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['open'] };
68+
if ((VIEW_FILTER_PAIR_VALUE_OPERATORS as readonly string[]).includes(operator)) return { value: ['a', 'z'] };
69+
return { value: 'open' };
70+
}
71+
72+
describe('ViewFilterRule.operator: the typed input is canonical, the runtime fold is not narrowed', () => {
73+
it('compiles the canonical spelling on the rule and its carriers, and the door accepts it', () => {
74+
for (const rule of [canonical, canonicalOnListView, canonicalOnTab, canonicalOnPage]) {
75+
expect(ViewFilterRuleSchema.parse(rule).operator).toBe('equals');
76+
}
77+
});
78+
79+
it('folds an alias the type refuses to the canonical id the alias table names', () => {
80+
const expected = VIEW_FILTER_OPERATOR_ALIASES.eq;
81+
// The table's answer is itself a canonical member, so this is not vacuous.
82+
expect((VIEW_FILTER_OPERATORS as readonly string[]).includes(expected ?? '')).toBe(true);
83+
for (const rule of [alias, aliasOnListView, aliasOnTab, aliasOnPage]) {
84+
expect(ViewFilterRuleSchema.parse(rule).operator).toBe(expected);
85+
}
86+
});
87+
88+
it('folds EVERY declared alias at run time', () => {
89+
const entries = Object.entries(VIEW_FILTER_OPERATOR_ALIASES);
90+
expect(entries.length).toBeGreaterThan(0);
91+
for (const [spelling, target] of entries) {
92+
const rule = { field: 'status', operator: spelling, ...valueFor(target) };
93+
expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target);
94+
}
95+
});
96+
97+
it('still reaches the case-folded branch of the fold (`GT`, `NotIn`)', () => {
98+
// The fold lower-cases a spelling the table does not hold verbatim; the
99+
// expected ids are read off the table under the lower-cased key.
100+
const cases = [['GT', 'gt'], ['NotIn', 'notin']] as const;
101+
for (const [spelling, key] of cases) {
102+
const target = VIEW_FILTER_OPERATOR_ALIASES[key];
103+
expect(target, key).toBeDefined();
104+
const rule = { field: 'status', operator: spelling, ...valueFor(target as ViewFilterOperator) };
105+
expect(ViewFilterRuleSchema.parse(rule).operator, spelling).toBe(target);
106+
}
107+
});
108+
109+
it("refuses a non-string the type refuses, with the enum's own issue at `operator`", () => {
110+
for (const rule of [numeric, numericOnListView]) {
111+
const result = ViewFilterRuleSchema.safeParse(rule);
112+
expect(result.success).toBe(false);
113+
const issues = result.error?.issues ?? [];
114+
expect(issues).toHaveLength(1);
115+
const [issue] = issues;
116+
expect(issue).toMatchObject({ code: 'invalid_value', path: ['operator'] });
117+
expect(issue && 'values' in issue ? issue.values : undefined).toEqual([...VIEW_FILTER_OPERATORS]);
118+
}
119+
});
120+
});

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper
663663
* ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed)
664664
*
665665
* 1. **`z.discriminatedUnion` cannot read this discriminator — it does not
666-
* construct.** `operator` is `z.preprocess(normalizeFilterOperator, z.enum(…))`
666+
* construct.** `operator` is a `z.preprocess` over `normalizeFilterOperator`
667667
* — the alias fold that lets a stored `notIn` / `nin` / `gt` parse. Zod 4
668668
* extracts a discriminator's literal values from the option's own def, and a
669669
* preprocess wrapper hides them: building the union throws
@@ -857,6 +857,38 @@ function checkViewFilterRuleTextComparand(
857857
});
858858
}
859859

860+
/**
861+
* [#20450] The preprocess half of `ViewFilterRule.operator`. Its PARAMETER type
862+
* is the rule's typed input.
863+
*
864+
* Zod types a `z.preprocess`'s INPUT from its function's parameter, so the
865+
* parameter here is what `ViewFilterRule` (a `z.input`) and every carrier of it
866+
* (`ListView.filter`, a tab filter, `Page.filterBy`, the component filter doors)
867+
* publish for `operator`. It is the canonical {@link ViewFilterOperator}, because
868+
* the alias table's own contract is that new producers emit canonical ids.
869+
* Before this, the parameter was `normalizeFilterOperator`'s `unknown`, so
870+
* `{ field: 'status', operator: 42 }` compiled as a rule on every carrier and was
871+
* refused only at parse time.
872+
*
873+
* The RUNTIME is unchanged. The body hands whatever arrived to the exported
874+
* {@link normalizeFilterOperator}, so a stored `sys_metadata` row or a plain-JS
875+
* producer that carries an alias still parses and folds, and a non-string still
876+
* reaches the enum and is refused there. The parameter type is therefore
877+
* deliberately narrower than what this function receives: ⛔ never narrow its
878+
* body on it.
879+
*
880+
* ⛔ Not an annotation on `normalizeFilterOperator` itself. That export exists
881+
* so producers and renderers can fold UNTYPED stored metadata, and its callers
882+
* pass raw strings and `unknown` by design: `@objectstack/lint`'s
883+
* preset-comparand check and `@objectstack/rest`'s rule lowering pass a stored
884+
* rule's `operator`, and the conversion registry folds an AST operator and the
885+
* literal `'eq'`. Narrowing that parameter breaks them, or pushes a cast into
886+
* each, to change a type only this schema publishes.
887+
*/
888+
function foldAuthoredViewFilterOperator(op: ViewFilterOperator): string {
889+
return normalizeFilterOperator(op);
890+
}
891+
860892
/**
861893
* View Filter Rule Schema
862894
* Standardized filter condition used in list views, tabs, and page-level filters.
@@ -916,11 +948,14 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({
916948
/** Field name to filter on */
917949
field: z.string().describe('Field name to filter on'),
918950
/**
919-
* Filter operator (canonical vocabulary). Legacy shorthand/camelCase
920-
* spellings (`eq`, `gt`, `isNull`, …) are accepted and normalized to
921-
* canonical on parse.
951+
* Filter operator (canonical vocabulary). The TYPED input is the canonical
952+
* {@link ViewFilterOperator}: a typed author writing an alias or a
953+
* non-string is refused at compile time. At RUNTIME the legacy
954+
* shorthand/camelCase spellings (`eq`, `gt`, `isNull`, …) that stored
955+
* metadata and plain-JS producers carry are still accepted and normalized
956+
* to canonical on parse — see {@link foldAuthoredViewFilterOperator}.
922957
*/
923-
operator: z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS))
958+
operator: z.preprocess(foldAuthoredViewFilterOperator, z.enum(VIEW_FILTER_OPERATORS))
924959
.describe('Filter operator'),
925960
/**
926961
* Filter value (optional for unary operators like is_empty, is_null).

0 commit comments

Comments
 (0)