Skip to content

Commit 40a8170

Browse files
committed
chore(spec): register the view filter operator input narrowing and add its changeset
Adds the semantic migration entry view-filter-rule-operator-input-canonical (a type-surface narrowing carried to the upgrade guide, nothing at rest rewritten), regenerates the migration registry regions, and adds the BREAKING-narrowing minor changeset with its FROM -> TO table and ADR-0087 registered disposition. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60102b7 commit 40a8170

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a view filter rule's `operator` is typed as the canonical `ViewFilterOperator`, not `unknown`
6+
7+
**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.
8+
9+
`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.
10+
11+
What does not change:
12+
13+
- **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.
14+
- **`normalizeFilterOperator`.** Its parameter stays `unknown`: it exists to fold untyped stored metadata, and its callers pass raw strings by design.
15+
- **The parsed type.** `ViewFilterRuleParsed['operator']` was already the canonical enum.
16+
17+
## FROM → TO
18+
19+
| Wrote (TypeScript) | Write instead |
20+
| --- | --- |
21+
| `{ field: 'status', operator: 'eq', value: 'open' }` | `{ field: 'status', operator: 'equals', value: 'open' }` |
22+
| `{ field: 'amount', operator: 'gte', value: 100 }` | `{ field: 'amount', operator: 'greater_than_or_equal', value: 100 }` |
23+
| `{ field: 'stage', operator: 'notIn', value: ['lost'] }` | `{ field: 'stage', operator: 'not_in', value: ['lost'] }` |
24+
| `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 |
25+
26+
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.
27+
28+
Clause-②: yes (narrowing)
29+
30+
<!-- adr-0087: registered view-filter-rule-operator-input-canonical -->
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// A TYPE-surface narrowing registered here for the reason
6+
// `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript
7+
// declaration, so there is no stored source for a D2 conversion to rewrite, and
8+
// the compiler error it produces names the canonical vocabulary but not which
9+
// member an alias spelling maps to. This guide is the channel that carries that
10+
// second half. Nothing at rest moves and the runtime accept set is unchanged.
11+
export const entry: SemanticMigration = {
12+
id: 'view-filter-rule-operator-input-canonical',
13+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
14+
// span already, and a nested backtick would close it.
15+
surface:
16+
'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every '
17+
+ 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the '
18+
+ 'related-list, record-picker and object-* block filter doors)',
19+
replacement:
20+
'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A '
21+
+ 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps '
22+
+ 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to '
23+
+ 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, '
24+
+ 'isNull to is_null, and the rest). A value that is not yet known to be an operator — '
25+
+ 'read from storage, a URL or user input — is typed unknown and handed to '
26+
+ 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the '
27+
+ 'schema stays the judge',
28+
reason:
29+
'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s '
30+
+ 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a '
31+
+ 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, '
32+
+ 'compiled on every carrier and was refused only when the door parsed it. The typed input '
33+
+ 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract '
34+
+ 'says new producers emit. '
35+
+ 'The RUNTIME does not move: the door still folds every spelling it folded before to '
36+
+ 'canonical and still refuses a non-string with the enum\'s own '
37+
+ 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS '
38+
+ 'producer that carries an alias keep parsing exactly as before, and os validate answers '
39+
+ 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 '
40+
+ 'conversion: what narrows is only what TypeScript source may write. '
41+
+ 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists '
42+
+ 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / '
43+
+ 'ADR-0122.',
44+
acceptanceCriteria:
45+
'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a '
46+
+ 'non-string, naming the canonical vocabulary. Rewrite each alias to the id '
47+
+ 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the '
48+
+ 'door already folded it to that id — and for a value typed string that is really '
49+
+ 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need '
50+
+ 'no action: they load and parse as before.',
51+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16902,6 +16902,53 @@ const step18: MigrationStep = {
1690216902
+ 'query before this change, so re-check what it is supposed to show rather than assuming '
1690316903
+ 'any earlier result set.',
1690416904
},
16905+
// A TYPE-surface narrowing registered here for the reason
16906+
// `spec-type-alias-input-suffix-retired` is: the surface is a TypeScript
16907+
// declaration, so there is no stored source for a D2 conversion to rewrite, and
16908+
// the compiler error it produces names the canonical vocabulary but not which
16909+
// member an alias spelling maps to. This guide is the channel that carries that
16910+
// second half. Nothing at rest moves and the runtime accept set is unchanged.
16911+
{
16912+
id: 'view-filter-rule-operator-input-canonical',
16913+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
16914+
// span already, and a nested backtick would close it.
16915+
surface:
16916+
'ui.ViewFilterRule operator — the TypeScript INPUT type of a view filter rule, on every '
16917+
+ 'carrier of ViewFilterRuleSchema (ListView.filter, a view tab filter, Page.filterBy, the '
16918+
+ 'related-list, record-picker and object-* block filter doors)',
16919+
replacement:
16920+
'the canonical operator id, a member of ViewFilterOperator (VIEW_FILTER_OPERATORS). A '
16921+
+ 'typed rule written operator: "eq" becomes operator: "equals"; every legacy spelling maps '
16922+
+ 'to exactly one canonical id, and VIEW_FILTER_OPERATOR_ALIASES is that map (ne and neq to '
16923+
+ 'not_equals, gt to greater_than, gte to greater_than_or_equal, nin and notIn to not_in, '
16924+
+ 'isNull to is_null, and the rest). A value that is not yet known to be an operator — '
16925+
+ 'read from storage, a URL or user input — is typed unknown and handed to '
16926+
+ 'ViewFilterRuleSchema.safeParse, or folded with normalizeFilterOperator first; the '
16927+
+ 'schema stays the judge',
16928+
reason:
16929+
'The operator key is a z.preprocess over the alias fold, and zod types a preprocess\'s '
16930+
+ 'INPUT from its function\'s parameter. That parameter was unknown, so ViewFilterRule (a '
16931+
+ 'z.input) typed operator as unknown: a rule with operator: 42, or any string at all, '
16932+
+ 'compiled on every carrier and was refused only when the door parsed it. The typed input '
16933+
+ 'is now the canonical ViewFilterOperator, the vocabulary the alias table\'s own contract '
16934+
+ 'says new producers emit. '
16935+
+ 'The RUNTIME does not move: the door still folds every spelling it folded before to '
16936+
+ 'canonical and still refuses a non-string with the enum\'s own '
16937+
+ 'issue at operator, so a stored sys_metadata row, a YAML or JSON body, and a plain-JS '
16938+
+ 'producer that carries an alias keep parsing exactly as before, and os validate answers '
16939+
+ 'as before. Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 '
16940+
+ 'conversion: what narrows is only what TypeScript source may write. '
16941+
+ 'The exported normalizeFilterOperator keeps its unknown parameter on purpose — it exists '
16942+
+ 'to fold untyped stored metadata, and its callers pass raw strings by design. ADR-0087 / '
16943+
+ 'ADR-0122.',
16944+
acceptanceCriteria:
16945+
'Your TypeScript compiles: tsc reports each typed rule whose operator is an alias or a '
16946+
+ 'non-string, naming the canonical vocabulary. Rewrite each alias to the id '
16947+
+ 'VIEW_FILTER_OPERATOR_ALIASES maps it to — the rule selects the same rows, because the '
16948+
+ 'door already folded it to that id — and for a value typed string that is really '
16949+
+ 'unvalidated input, type it unknown and parse it rather than casting it. Stored views need '
16950+
+ 'no action: they load and parse as before.',
16951+
},
1690516952
// The scalar half of the coupling #6227 declared and did not judge. Recorded
1690616953
// here rather than amended onto `view-filter-rule-value-shaped-by-operator`
1690716954
// because that entry's own prose states the OPPOSITE reading as accepted, and

0 commit comments

Comments
 (0)