Skip to content

Commit b49ccfd

Browse files
os-zhuangclaude
andauthored
feat(spec): 声明 FilterArray 为仅输入的授权糖(#5158 拍板 C 第 1 步) (#5306)
* feat(spec): declare FilterArray as input-only authoring sugar (#5285) `FilterArray` was a name with no definition. Three READMEs, `llms.txt`, four skills, the query-adapter docs and this package's own react-blocks prop table all taught authors to write it, while the protocol never declared it anywhere. Declare it in `data/filter.zod.ts`, beside the operator vocabulary it is built from and the sink it lowers through: - `FilterArray` + `FilterArrayComparison` / `FilterArrayGroup` / `FilterArrayList` — the three shapes the measured producers emit - `FilterArraySchema` — the Zod authoring gate - `FilterArrayOperator` — canonical spellings, derived from `AST_OPERATOR_MAP` rather than restated, so it cannot drift from the lowering (#3948) - `FILTER_ARRAY_LOGIC_KEYWORDS` / `FilterArrayLogicKeyword` Input-only: lowered to a `FilterCondition` at the single sink `parseFilterAST` on arrival. The storage/wire contract is unchanged — a query's `where` is a `FilterCondition` and deliberately does NOT accept the array dialect, pinned as a negative test so a future widening of the protocol face fails loudly. `AST_OPERATOR_MAP` keeps its literal key set via `satisfies` so the operator type can be derived; the two runtime lookups go through one `astOperatorLowering` helper. Behaviour identical. Step 1 of #5158's ruling C. No engine or driver changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * chore(spec): regenerate os-regen artifacts against main @ 04fab5e (#5285) Serial-landing sync for PR #5306. `git merge origin/main`, then all NINE os-regen paths reset to origin/main and regenerated wholesale from the merged sources (the path list read from the merged tree's `.gitattributes`, not from memory — #5304 added `authorable-surface.base.json` as the ninth). The merge driver had deferred `api-surface.json` and `json-schema.manifest.json`, and git's textual result RESURRECTED symbols three sibling PRs had retired — this branch's pre-merge copies still listed them. Wholesale regeneration removes them again: - #5293: HttpServerConfig / HttpServerConfigInput / HttpServerConfigSchema - #5289: Animation / AnimationSchema / ZIndex / ZIndexSchema - #5300: EmbedConfig / EmbedConfigSchema / NotificationAction / NotificationActionSchema Verified after regeneration: this PR's 7 exports and `data/FilterArray` still present, the FilterArray docs section still carries its describe text, all three siblings' retirements absent from every witness, the #5304 anchor authentic (baseRev an ancestor of origin/main, keys identical line-for-line to that commit's surface), and `check:merge-driver` reconciling 9 paths both ways. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2cca98b commit b49ccfd

6 files changed

Lines changed: 533 additions & 6 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): declare `FilterArray` as the input-only authoring sugar it already was (#5285)
6+
7+
`FilterArray` had a name and no definition. Three READMEs, `llms.txt`, four
8+
skills, the query-adapter docs and this package's own react-blocks prop table
9+
all taught authors to write it — `filters={['status', '=', stage]}` — while the
10+
protocol never declared it anywhere. An author following the contract they were
11+
handed had nothing to validate against, which is worst for the AI authors these
12+
contracts are largely written for: the name looked resolvable and was not.
13+
14+
`packages/spec/src/data/filter.zod.ts` now declares it, next to the operator
15+
vocabulary it is built from and the sink it lowers through:
16+
17+
- **`FilterArray`** (type) plus `FilterArrayComparison`, `FilterArrayGroup`,
18+
`FilterArrayList` — the three shapes the measured producers emit: a comparison
19+
`[field, operator, value]` (with the real two-element form for the null
20+
predicates, whose direction lives in the operator name), a group
21+
`['and' | 'or', ...conditions]`, and a bare list combined with implicit AND.
22+
- **`FilterArraySchema`** — the Zod authoring gate.
23+
- **`FilterArrayOperator`** — the canonical operator spellings, derived from
24+
`AST_OPERATOR_MAP` rather than restated, so it cannot drift from the lowering
25+
the way two hand-written lists did in #3948. A misspelled operator is now a
26+
type error where the shape is authored in TypeScript, instead of an unknown
27+
`$`-operator arriving at a driver.
28+
- **`FILTER_ARRAY_LOGIC_KEYWORDS`** / `FilterArrayLogicKeyword``'and' | 'or'`.
29+
30+
**Input-only, and that is the whole point.** This is sugar accepted at
31+
authoring entrances (React block props, the client `FilterBuilder`, the wire
32+
`$filter` face). It is lowered to a `FilterCondition` at the single sink
33+
`parseFilterAST` the moment it arrives, and only the lowered form travels any
34+
further. **Nothing about the storage or wire contract changes**: a query's
35+
`where` is a `FilterCondition` and stays one, deliberately excluding the array
36+
dialect so no driver, transport or stored row ever has to understand two filter
37+
languages. `filter-array-declaration.test.ts` pins that exclusion as a negative
38+
test, so a future widening of the protocol face fails loudly and lands the
39+
reader on the ruling that decided it.
40+
41+
Nothing to migrate: every filter that worked before works unchanged, and the
42+
declaration adds the check that was missing. Per #5158's ruling C, this is step
43+
one of two — the engine-side lowering that closes the second door is separate.

content/docs/references/data/filter.mdx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ Design Principles:
4242
## TypeScript Usage
4343

4444
```typescript
45-
import { EqualityOperatorSchema, FieldReferenceSchema, FilterConditionSchema, QueryFilterSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data';
46-
import type { FieldReference, FilterCondition, QueryFilter } from '@objectstack/spec/data';
45+
import { EqualityOperatorSchema, FieldReferenceSchema, FilterArraySchema, FilterConditionSchema, QueryFilterSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data';
46+
import type { FieldReference, FilterArray, FilterCondition, QueryFilter } from '@objectstack/spec/data';
4747

4848
// Validate data
4949
const result = EqualityOperatorSchema.parse(data);
@@ -72,6 +72,41 @@ const result = EqualityOperatorSchema.parse(data);
7272
| **$field** | `string` || Field Reference/Column Name |
7373

7474

75+
---
76+
77+
## FilterArray
78+
79+
Input-only authoring sugar for a filter: [field, operator, value], ["and"|"or", ...conditions], or a bare list of those. Lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives; it is never stored and never travels the wire as an array. A query "where" is a FilterCondition and does not accept this shape (#5158).
80+
81+
### Union Options
82+
83+
This schema accepts one of the following structures:
84+
85+
#### Option 1
86+
87+
Type: `any[]`
88+
89+
---
90+
91+
#### Option 2
92+
93+
Type: `any[]`
94+
95+
---
96+
97+
#### Option 3
98+
99+
Type: `[FilterArray](#filterarray)[]`
100+
101+
---
102+
103+
#### Option 4
104+
105+
Type: `[FilterArray](#filterarray)[]`
106+
107+
---
108+
109+
75110
---
76111

77112

packages/spec/api-surface.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@
361361
"FIELD_GROUP_SYSTEM_FIELDS (const)",
362362
"FIELD_KEY_GUIDANCE (const)",
363363
"FILE_REFERENCE_TYPES (const)",
364+
"FILTER_ARRAY_LOGIC_KEYWORDS (const)",
364365
"FILTER_LOGIC_CASES (const)",
365366
"FILTER_LOGIC_ROWS (const)",
366367
"FILTER_OPERATORS (const)",
@@ -385,6 +386,13 @@
385386
"FileReferenceIdValueSchema (const)",
386387
"FileValueSchema (const)",
387388
"Filter (type)",
389+
"FilterArray (type)",
390+
"FilterArrayComparison (type)",
391+
"FilterArrayGroup (type)",
392+
"FilterArrayList (type)",
393+
"FilterArrayLogicKeyword (type)",
394+
"FilterArrayOperator (type)",
395+
"FilterArraySchema (const)",
388396
"FilterCondition (type)",
389397
"FilterConditionSchema (const)",
390398
"FilterLogicCase (interface)",

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,7 @@
736736
"data/FilePersistenceConfig",
737737
"data/FileReferenceIdValue",
738738
"data/FileValue",
739+
"data/FilterArray",
739740
"data/FilterCondition",
740741
"data/FormatValidation",
741742
"data/FullTextSearch",
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `FilterArray` is DECLARED, and it is declared INPUT-ONLY. (#5158, ruling C)
5+
*
6+
* Before this file, `FilterArray` was a name with no definition: three READMEs,
7+
* `llms.txt`, four skills, the query-adapter docs and this package's own
8+
* react-blocks prop table all taught authors to write it, and the protocol
9+
* never declared it anywhere. An AI author following the contract it was handed
10+
* had nothing to check its work against.
11+
*
12+
* The maintainer's ruling (2026-08-04 15:22Z on #5158) was **C — one lowering
13+
* sink**: declare the shape as input-only sugar, keep the wire/storage contract
14+
* exactly as it is, and lower every arrival through `parseFilterAST`. Option A
15+
* (widen `where` to accept the array dialect, so every driver and transport
16+
* maintains two compilers forever) was rejected. So this file pins BOTH halves,
17+
* and the negative half is the load-bearing one:
18+
*
19+
* 1. the declaration exists and matches the shapes the measured producers emit;
20+
* 2. a query's `where` does **not** accept it — a future "helpful" widening of
21+
* the protocol face turns this red and lands the reader back on #5158.
22+
*
23+
* It also pins the two deliberate strictnesses that separate this authoring
24+
* gate from the runtime detector `isFilterAST`, so that list cannot grow by
25+
* accident.
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import {
30+
FilterArraySchema,
31+
FILTER_ARRAY_LOGIC_KEYWORDS,
32+
VALID_AST_OPERATORS,
33+
isFilterAST,
34+
parseFilterAST,
35+
FilterConditionSchema,
36+
type FilterArray,
37+
type FilterArrayOperator,
38+
} from './filter.zod';
39+
import { QuerySchema } from './query.zod';
40+
41+
/** The shapes the measured producers actually emit (see the file header). */
42+
const PRODUCED: ReadonlyArray<{ label: string; value: FilterArray }> = [
43+
// `FilterBuilder.equals()` / a `<ListView filters={…}>` prop.
44+
{ label: 'comparison', value: ['status', '=', 'active'] },
45+
// `FilterBuilder.isNull()` — direction is in the operator name, so no value.
46+
{ label: 'comparison, two-element null predicate', value: ['deleted_at', 'is_null'] },
47+
// `FilterBuilder.build()` with more than one condition.
48+
{ label: 'group', value: ['and', ['stage', '=', 'won'], ['amount', '>', 1000]] },
49+
{ label: 'group, or', value: ['or', ['stage', '=', 'won'], ['stage', '=', 'lost']] },
50+
// `FilterBuilder.between()` nests a group inside a group.
51+
{ label: 'nested group', value: ['and', ['or', ['a', '=', 1], ['b', '=', 2]], ['c', '=', 3]] },
52+
// `examples/app-showcase/src/ui/pages/my-work.page.ts:52`.
53+
{ label: 'bare list, implicit AND', value: [['owner_id', '=', '{current_user_id}']] },
54+
{ label: 'bare list, two conditions', value: [['a', '=', 1], ['b', '>', 2]] },
55+
// Set / range / string operators carry non-scalar values.
56+
{ label: 'in', value: ['role', 'in', ['admin', 'editor']] },
57+
{ label: 'between', value: ['age', 'between', [18, 65]] },
58+
];
59+
60+
describe('FilterArray is declared', () => {
61+
it('accepts every shape the measured producers emit', () => {
62+
for (const { label, value } of PRODUCED) {
63+
const result = FilterArraySchema.safeParse(value);
64+
expect(
65+
result.success,
66+
`${label}: ${JSON.stringify(value)}${result.success ? '' : JSON.stringify(result.error.issues)}`,
67+
).toBe(true);
68+
}
69+
});
70+
71+
it('every produced shape lowers to a FilterCondition through the declared sink', () => {
72+
// The declaration's whole semantic claim: what this schema accepts,
73+
// `parseFilterAST` turns into something `where` DOES accept. If these ever
74+
// disagree the declaration is lying about where the shape goes.
75+
for (const { label, value } of PRODUCED) {
76+
const lowered = parseFilterAST(value);
77+
expect(lowered, label).toBeDefined();
78+
expect(FilterConditionSchema.safeParse(lowered).success, label).toBe(true);
79+
}
80+
});
81+
82+
it('rejects the non-filter arrays that used to be misread as filters', () => {
83+
// The shapes `isFilterAST` was written to refuse (#4121) — a naive
84+
// `Array.isArray` read them as filters and the driver had to cope.
85+
for (const value of [[1, 2, 3], ['and'], ['or'], [], 'not an array', { status: 'active' }, null]) {
86+
expect(FilterArraySchema.safeParse(value).success, JSON.stringify(value)).toBe(false);
87+
}
88+
});
89+
90+
it('accepts every operator in the vocabulary, in the position it is read from', () => {
91+
for (const op of VALID_AST_OPERATORS) {
92+
const result = FilterArraySchema.safeParse(['some_field', op, 'v']);
93+
expect(result.success, `operator '${op}'`).toBe(true);
94+
}
95+
});
96+
97+
it('rejects an operator outside the vocabulary', () => {
98+
// The silent failure this closes: `convertComparison` lowers an unknown
99+
// spelling to `$equalss` and hands it to a driver that has never heard of
100+
// it. Authoring-time rejection names the vocabulary instead.
101+
const result = FilterArraySchema.safeParse(['status', 'equalss', 'won']);
102+
expect(result.success).toBe(false);
103+
expect(JSON.stringify(result.success ? [] : result.error.issues)).toContain('equalss');
104+
});
105+
106+
it('folds operator case the same way every door folds it', () => {
107+
// Load-bearing, not cosmetic: already-stored view metadata carries camelCase
108+
// spellings (`VIEW_FILTER_OPERATOR_ALIASES`, `ui/view.zod.ts`), and every
109+
// door lowercases before lookup. A case-sensitive gate here would reject
110+
// filters the wire accepts today.
111+
for (const op of ['IN', 'startsWith', 'notEquals', 'Is_Null']) {
112+
expect(FilterArraySchema.safeParse(['some_field', op, 'v']).success, op).toBe(
113+
isFilterAST(['some_field', op, 'v']),
114+
);
115+
}
116+
});
117+
118+
it('reserves the logic keywords for the group reading', () => {
119+
for (const kw of FILTER_ARRAY_LOGIC_KEYWORDS) {
120+
// As a field name: refused, because that reading is taken (and
121+
// `isFilterAST` refuses it too — it commits to the group reading and then
122+
// fails to find children).
123+
expect(FilterArraySchema.safeParse([kw, '=', true]).success, kw).toBe(false);
124+
expect(isFilterAST([kw, '=', true]), kw).toBe(false);
125+
// As a group opener: accepted.
126+
expect(FilterArraySchema.safeParse([kw, ['a', '=', 1]]).success, kw).toBe(true);
127+
}
128+
});
129+
});
130+
131+
describe('FilterArray is INPUT-ONLY — it is not part of the wire contract', () => {
132+
/**
133+
* THE NEGATIVE PIN (#5158 ruling C, step 1).
134+
*
135+
* If this goes red, someone widened the protocol face to accept the array
136+
* dialect — that is rejected option A, and it puts two filter compilers back
137+
* into every driver and transport. Read #5158 before changing this file.
138+
*/
139+
it('a query `where` does NOT accept the array dialect', () => {
140+
for (const { label, value } of PRODUCED) {
141+
const result = QuerySchema.safeParse({ object: 'showcase_project', where: value });
142+
expect(result.success, `where accepted a FilterArray (${label}) — see #5158`).toBe(false);
143+
}
144+
});
145+
146+
it('`FilterCondition` itself does not accept the array dialect', () => {
147+
// One layer down from `where`, so the exclusion cannot be re-introduced by
148+
// widening the condition type instead of the query.
149+
for (const { label, value } of PRODUCED) {
150+
expect(FilterConditionSchema.safeParse(value).success, label).toBe(false);
151+
}
152+
});
153+
154+
it('the lowered form IS what `where` accepts', () => {
155+
// The other direction of the same claim: the sugar is not refused because
156+
// the filter is bad, it is refused because it has not been lowered yet.
157+
for (const { label, value } of PRODUCED) {
158+
const result = QuerySchema.safeParse({
159+
object: 'showcase_project',
160+
where: parseFilterAST(value),
161+
});
162+
expect(result.success, `${label}: ${JSON.stringify(result.success ? '' : result.error.issues)}`).toBe(true);
163+
}
164+
});
165+
});
166+
167+
describe('the authoring gate is stricter than the runtime detector, in exactly two places', () => {
168+
/**
169+
* `isFilterAST` tolerates these by accident; no measured producer emits them;
170+
* each is unambiguously an author error. Enumerated here so the divergence
171+
* list is a fact on the record rather than something a future reader has to
172+
* re-derive by diffing two functions.
173+
*/
174+
const DELIBERATELY_STRICTER: ReadonlyArray<{ label: string; value: unknown }> = [
175+
{ label: 'trailing elements past the value position', value: ['a', '=', 1, 2] },
176+
{ label: 'empty field name', value: ['', '=', 1] },
177+
];
178+
179+
it('rejects what `isFilterAST` accepts, only on this list', () => {
180+
for (const { label, value } of DELIBERATELY_STRICTER) {
181+
expect(isFilterAST(value), `${label} — runtime detector`).toBe(true);
182+
expect(FilterArraySchema.safeParse(value).success, `${label} — authoring gate`).toBe(false);
183+
}
184+
});
185+
186+
it('agrees with `isFilterAST` on everything else', () => {
187+
const agree: unknown[] = [
188+
...PRODUCED.map((p) => p.value),
189+
[1, 2, 3],
190+
['and'],
191+
[],
192+
'not an array',
193+
{ status: 'active' },
194+
['status', 'equalss', 'won'],
195+
['and', '=', true],
196+
['some_field', 'startsWith', 'A'],
197+
];
198+
for (const value of agree) {
199+
expect(FilterArraySchema.safeParse(value).success, JSON.stringify(value) ?? String(value)).toBe(
200+
isFilterAST(value),
201+
);
202+
}
203+
});
204+
});
205+
206+
/**
207+
* ⚠️ **These assertions do not run in CI, and saying so is the point.**
208+
*
209+
* `packages/spec/tsconfig.json` excludes `**` + `/*.test.ts` under the measured
210+
* `TEST_DEBT` entry in `scripts/check-type-check-coverage.mjs` (272 test files,
211+
* 902 errors), so `pnpm --filter @objectstack/spec typecheck` never reads this
212+
* file and every `@ts-expect-error` below is INERT — it looks like a pinned
213+
* contract and pins nothing. That is true of all 17 `@ts-expect-error`
214+
* directives across spec's test layer, not just these two; filed as #5305.
215+
*
216+
* They are kept because they are correct and become live the day spec
217+
* graduates off `TEST_DEBT`. Verified by hand on this branch, with the
218+
* exclusion lifted:
219+
*
220+
* ```
221+
* # tsconfig extending spec's, "include": [this file], "exclude": []
222+
* npx tsc -p tsconfig.typetest.tmp.json # => exit 0, both directives live
223+
* ```
224+
*
225+
* and reverse-verified by widening `FilterArrayOperator` back to `string`
226+
* (restoring the `Record< string, string >` annotation on `AST_OPERATOR_MAP`),
227+
* which reports exactly one new error — `TS2578: Unused '@ts-expect-error'
228+
* directive` on the misspelled-operator line, the narrowing this declaration
229+
* adds. Do not read a green `pnpm test` as evidence for anything in this block.
230+
*/
231+
describe('FilterArray type-level declaration (NOT type-checked in CI — see above)', () => {
232+
it('narrows the operator position to the canonical vocabulary', () => {
233+
const canonical: FilterArrayOperator = 'starts_with';
234+
const comparison: FilterArray = ['name', canonical, 'A'];
235+
const group: FilterArray = ['and', ['a', '=', 1], ['b', '>', 2]];
236+
const list: FilterArray = [['a', '=', 1], ['b', '>', 2]];
237+
// @ts-expect-error — 'equalss' is not in the operator vocabulary.
238+
const misspelled: FilterArray = ['status', 'equalss', 'won'];
239+
// @ts-expect-error — a group needs at least one condition.
240+
const empty: FilterArray = ['and'];
241+
242+
expect([comparison, group, list, misspelled, empty]).toHaveLength(5);
243+
});
244+
});

0 commit comments

Comments
 (0)