Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/20450-view-filter-operator-input-typed.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered view-filter-rule-operator-input-canonical -->
Original file line number Diff line number Diff line change
@@ -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.',
};
47 changes: 47 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions packages/spec/src/ui/view-filter-operator-input-typed.test.ts
Original file line number Diff line number Diff line change
@@ -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<ListView['filter']>[number];
type TabFilterRule = NonNullable<ViewTab['filter']>[number];
type PageFilterRule = NonNullable<InterfacePageConfig['filterBy']>[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]);
}
});
});
Loading
Loading