diff --git a/.changeset/filter-array-input-only-declaration.md b/.changeset/filter-array-input-only-declaration.md new file mode 100644 index 0000000000..e6fab7f14a --- /dev/null +++ b/.changeset/filter-array-input-only-declaration.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): declare `FilterArray` as the input-only authoring sugar it already was (#5285) + +`FilterArray` had a name and 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 — `filters={['status', '=', stage]}` — while the +protocol never declared it anywhere. An author following the contract they were +handed had nothing to validate against, which is worst for the AI authors these +contracts are largely written for: the name looked resolvable and was not. + +`packages/spec/src/data/filter.zod.ts` now declares it, next to the operator +vocabulary it is built from and the sink it lowers through: + +- **`FilterArray`** (type) plus `FilterArrayComparison`, `FilterArrayGroup`, + `FilterArrayList` — the three shapes the measured producers emit: a comparison + `[field, operator, value]` (with the real two-element form for the null + predicates, whose direction lives in the operator name), a group + `['and' | 'or', ...conditions]`, and a bare list combined with implicit AND. +- **`FilterArraySchema`** — the Zod authoring gate. +- **`FilterArrayOperator`** — the canonical operator spellings, derived from + `AST_OPERATOR_MAP` rather than restated, so it cannot drift from the lowering + the way two hand-written lists did in #3948. A misspelled operator is now a + type error where the shape is authored in TypeScript, instead of an unknown + `$`-operator arriving at a driver. +- **`FILTER_ARRAY_LOGIC_KEYWORDS`** / `FilterArrayLogicKeyword` — `'and' | 'or'`. + +**Input-only, and that is the whole point.** This is sugar accepted at +authoring entrances (React block props, the client `FilterBuilder`, the wire +`$filter` face). It is lowered to a `FilterCondition` at the single sink +`parseFilterAST` the moment it arrives, and only the lowered form travels any +further. **Nothing about the storage or wire contract changes**: a query's +`where` is a `FilterCondition` and stays one, deliberately excluding the array +dialect so no driver, transport or stored row ever has to understand two filter +languages. `filter-array-declaration.test.ts` pins that exclusion as a negative +test, so a future widening of the protocol face fails loudly and lands the +reader on the ruling that decided it. + +Nothing to migrate: every filter that worked before works unchanged, and the +declaration adds the check that was missing. Per #5158's ruling C, this is step +one of two — the engine-side lowering that closes the second door is separate. diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index f377f4fd4a..e490023a01 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -42,8 +42,8 @@ Design Principles: ## TypeScript Usage ```typescript -import { EqualityOperatorSchema, FieldReferenceSchema, FilterConditionSchema, QueryFilterSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data'; -import type { FieldReference, FilterCondition, QueryFilter } from '@objectstack/spec/data'; +import { EqualityOperatorSchema, FieldReferenceSchema, FilterArraySchema, FilterConditionSchema, QueryFilterSchema, SetOperatorSchema, SpecialOperatorSchema, StringOperatorSchema } from '@objectstack/spec/data'; +import type { FieldReference, FilterArray, FilterCondition, QueryFilter } from '@objectstack/spec/data'; // Validate data const result = EqualityOperatorSchema.parse(data); @@ -72,6 +72,41 @@ const result = EqualityOperatorSchema.parse(data); | **$field** | `string` | ✅ | Field Reference/Column Name | +--- + +## FilterArray + +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). + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Type: `any[]` + +--- + +#### Option 2 + +Type: `any[]` + +--- + +#### Option 3 + +Type: `[FilterArray](#filterarray)[]` + +--- + +#### Option 4 + +Type: `[FilterArray](#filterarray)[]` + +--- + + --- diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index b23595550f..88e05ea272 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -361,6 +361,7 @@ "FIELD_GROUP_SYSTEM_FIELDS (const)", "FIELD_KEY_GUIDANCE (const)", "FILE_REFERENCE_TYPES (const)", + "FILTER_ARRAY_LOGIC_KEYWORDS (const)", "FILTER_LOGIC_CASES (const)", "FILTER_LOGIC_ROWS (const)", "FILTER_OPERATORS (const)", @@ -385,6 +386,13 @@ "FileReferenceIdValueSchema (const)", "FileValueSchema (const)", "Filter (type)", + "FilterArray (type)", + "FilterArrayComparison (type)", + "FilterArrayGroup (type)", + "FilterArrayList (type)", + "FilterArrayLogicKeyword (type)", + "FilterArrayOperator (type)", + "FilterArraySchema (const)", "FilterCondition (type)", "FilterConditionSchema (const)", "FilterLogicCase (interface)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 47bf185eda..335c675a19 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -736,6 +736,7 @@ "data/FilePersistenceConfig", "data/FileReferenceIdValue", "data/FileValue", + "data/FilterArray", "data/FilterCondition", "data/FormatValidation", "data/FullTextSearch", diff --git a/packages/spec/src/data/filter-array-declaration.test.ts b/packages/spec/src/data/filter-array-declaration.test.ts new file mode 100644 index 0000000000..41f640a11c --- /dev/null +++ b/packages/spec/src/data/filter-array-declaration.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `FilterArray` is DECLARED, and it is declared INPUT-ONLY. (#5158, ruling C) + * + * Before this file, `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, and the protocol + * never declared it anywhere. An AI author following the contract it was handed + * had nothing to check its work against. + * + * The maintainer's ruling (2026-08-04 15:22Z on #5158) was **C — one lowering + * sink**: declare the shape as input-only sugar, keep the wire/storage contract + * exactly as it is, and lower every arrival through `parseFilterAST`. Option A + * (widen `where` to accept the array dialect, so every driver and transport + * maintains two compilers forever) was rejected. So this file pins BOTH halves, + * and the negative half is the load-bearing one: + * + * 1. the declaration exists and matches the shapes the measured producers emit; + * 2. a query's `where` does **not** accept it — a future "helpful" widening of + * the protocol face turns this red and lands the reader back on #5158. + * + * It also pins the two deliberate strictnesses that separate this authoring + * gate from the runtime detector `isFilterAST`, so that list cannot grow by + * accident. + */ + +import { describe, it, expect } from 'vitest'; +import { + FilterArraySchema, + FILTER_ARRAY_LOGIC_KEYWORDS, + VALID_AST_OPERATORS, + isFilterAST, + parseFilterAST, + FilterConditionSchema, + type FilterArray, + type FilterArrayOperator, +} from './filter.zod'; +import { QuerySchema } from './query.zod'; + +/** The shapes the measured producers actually emit (see the file header). */ +const PRODUCED: ReadonlyArray<{ label: string; value: FilterArray }> = [ + // `FilterBuilder.equals()` / a `` prop. + { label: 'comparison', value: ['status', '=', 'active'] }, + // `FilterBuilder.isNull()` — direction is in the operator name, so no value. + { label: 'comparison, two-element null predicate', value: ['deleted_at', 'is_null'] }, + // `FilterBuilder.build()` with more than one condition. + { label: 'group', value: ['and', ['stage', '=', 'won'], ['amount', '>', 1000]] }, + { label: 'group, or', value: ['or', ['stage', '=', 'won'], ['stage', '=', 'lost']] }, + // `FilterBuilder.between()` nests a group inside a group. + { label: 'nested group', value: ['and', ['or', ['a', '=', 1], ['b', '=', 2]], ['c', '=', 3]] }, + // `examples/app-showcase/src/ui/pages/my-work.page.ts:52`. + { label: 'bare list, implicit AND', value: [['owner_id', '=', '{current_user_id}']] }, + { label: 'bare list, two conditions', value: [['a', '=', 1], ['b', '>', 2]] }, + // Set / range / string operators carry non-scalar values. + { label: 'in', value: ['role', 'in', ['admin', 'editor']] }, + { label: 'between', value: ['age', 'between', [18, 65]] }, +]; + +describe('FilterArray is declared', () => { + it('accepts every shape the measured producers emit', () => { + for (const { label, value } of PRODUCED) { + const result = FilterArraySchema.safeParse(value); + expect( + result.success, + `${label}: ${JSON.stringify(value)} — ${result.success ? '' : JSON.stringify(result.error.issues)}`, + ).toBe(true); + } + }); + + it('every produced shape lowers to a FilterCondition through the declared sink', () => { + // The declaration's whole semantic claim: what this schema accepts, + // `parseFilterAST` turns into something `where` DOES accept. If these ever + // disagree the declaration is lying about where the shape goes. + for (const { label, value } of PRODUCED) { + const lowered = parseFilterAST(value); + expect(lowered, label).toBeDefined(); + expect(FilterConditionSchema.safeParse(lowered).success, label).toBe(true); + } + }); + + it('rejects the non-filter arrays that used to be misread as filters', () => { + // The shapes `isFilterAST` was written to refuse (#4121) — a naive + // `Array.isArray` read them as filters and the driver had to cope. + for (const value of [[1, 2, 3], ['and'], ['or'], [], 'not an array', { status: 'active' }, null]) { + expect(FilterArraySchema.safeParse(value).success, JSON.stringify(value)).toBe(false); + } + }); + + it('accepts every operator in the vocabulary, in the position it is read from', () => { + for (const op of VALID_AST_OPERATORS) { + const result = FilterArraySchema.safeParse(['some_field', op, 'v']); + expect(result.success, `operator '${op}'`).toBe(true); + } + }); + + it('rejects an operator outside the vocabulary', () => { + // The silent failure this closes: `convertComparison` lowers an unknown + // spelling to `$equalss` and hands it to a driver that has never heard of + // it. Authoring-time rejection names the vocabulary instead. + const result = FilterArraySchema.safeParse(['status', 'equalss', 'won']); + expect(result.success).toBe(false); + expect(JSON.stringify(result.success ? [] : result.error.issues)).toContain('equalss'); + }); + + it('folds operator case the same way every door folds it', () => { + // Load-bearing, not cosmetic: already-stored view metadata carries camelCase + // spellings (`VIEW_FILTER_OPERATOR_ALIASES`, `ui/view.zod.ts`), and every + // door lowercases before lookup. A case-sensitive gate here would reject + // filters the wire accepts today. + for (const op of ['IN', 'startsWith', 'notEquals', 'Is_Null']) { + expect(FilterArraySchema.safeParse(['some_field', op, 'v']).success, op).toBe( + isFilterAST(['some_field', op, 'v']), + ); + } + }); + + it('reserves the logic keywords for the group reading', () => { + for (const kw of FILTER_ARRAY_LOGIC_KEYWORDS) { + // As a field name: refused, because that reading is taken (and + // `isFilterAST` refuses it too — it commits to the group reading and then + // fails to find children). + expect(FilterArraySchema.safeParse([kw, '=', true]).success, kw).toBe(false); + expect(isFilterAST([kw, '=', true]), kw).toBe(false); + // As a group opener: accepted. + expect(FilterArraySchema.safeParse([kw, ['a', '=', 1]]).success, kw).toBe(true); + } + }); +}); + +describe('FilterArray is INPUT-ONLY — it is not part of the wire contract', () => { + /** + * THE NEGATIVE PIN (#5158 ruling C, step 1). + * + * If this goes red, someone widened the protocol face to accept the array + * dialect — that is rejected option A, and it puts two filter compilers back + * into every driver and transport. Read #5158 before changing this file. + */ + it('a query `where` does NOT accept the array dialect', () => { + for (const { label, value } of PRODUCED) { + const result = QuerySchema.safeParse({ object: 'showcase_project', where: value }); + expect(result.success, `where accepted a FilterArray (${label}) — see #5158`).toBe(false); + } + }); + + it('`FilterCondition` itself does not accept the array dialect', () => { + // One layer down from `where`, so the exclusion cannot be re-introduced by + // widening the condition type instead of the query. + for (const { label, value } of PRODUCED) { + expect(FilterConditionSchema.safeParse(value).success, label).toBe(false); + } + }); + + it('the lowered form IS what `where` accepts', () => { + // The other direction of the same claim: the sugar is not refused because + // the filter is bad, it is refused because it has not been lowered yet. + for (const { label, value } of PRODUCED) { + const result = QuerySchema.safeParse({ + object: 'showcase_project', + where: parseFilterAST(value), + }); + expect(result.success, `${label}: ${JSON.stringify(result.success ? '' : result.error.issues)}`).toBe(true); + } + }); +}); + +describe('the authoring gate is stricter than the runtime detector, in exactly two places', () => { + /** + * `isFilterAST` tolerates these by accident; no measured producer emits them; + * each is unambiguously an author error. Enumerated here so the divergence + * list is a fact on the record rather than something a future reader has to + * re-derive by diffing two functions. + */ + const DELIBERATELY_STRICTER: ReadonlyArray<{ label: string; value: unknown }> = [ + { label: 'trailing elements past the value position', value: ['a', '=', 1, 2] }, + { label: 'empty field name', value: ['', '=', 1] }, + ]; + + it('rejects what `isFilterAST` accepts, only on this list', () => { + for (const { label, value } of DELIBERATELY_STRICTER) { + expect(isFilterAST(value), `${label} — runtime detector`).toBe(true); + expect(FilterArraySchema.safeParse(value).success, `${label} — authoring gate`).toBe(false); + } + }); + + it('agrees with `isFilterAST` on everything else', () => { + const agree: unknown[] = [ + ...PRODUCED.map((p) => p.value), + [1, 2, 3], + ['and'], + [], + 'not an array', + { status: 'active' }, + ['status', 'equalss', 'won'], + ['and', '=', true], + ['some_field', 'startsWith', 'A'], + ]; + for (const value of agree) { + expect(FilterArraySchema.safeParse(value).success, JSON.stringify(value) ?? String(value)).toBe( + isFilterAST(value), + ); + } + }); +}); + +/** + * ⚠️ **These assertions do not run in CI, and saying so is the point.** + * + * `packages/spec/tsconfig.json` excludes `**` + `/*.test.ts` under the measured + * `TEST_DEBT` entry in `scripts/check-type-check-coverage.mjs` (272 test files, + * 902 errors), so `pnpm --filter @objectstack/spec typecheck` never reads this + * file and every `@ts-expect-error` below is INERT — it looks like a pinned + * contract and pins nothing. That is true of all 17 `@ts-expect-error` + * directives across spec's test layer, not just these two; filed as #5305. + * + * They are kept because they are correct and become live the day spec + * graduates off `TEST_DEBT`. Verified by hand on this branch, with the + * exclusion lifted: + * + * ``` + * # tsconfig extending spec's, "include": [this file], "exclude": [] + * npx tsc -p tsconfig.typetest.tmp.json # => exit 0, both directives live + * ``` + * + * and reverse-verified by widening `FilterArrayOperator` back to `string` + * (restoring the `Record< string, string >` annotation on `AST_OPERATOR_MAP`), + * which reports exactly one new error — `TS2578: Unused '@ts-expect-error' + * directive` on the misspelled-operator line, the narrowing this declaration + * adds. Do not read a green `pnpm test` as evidence for anything in this block. + */ +describe('FilterArray type-level declaration (NOT type-checked in CI — see above)', () => { + it('narrows the operator position to the canonical vocabulary', () => { + const canonical: FilterArrayOperator = 'starts_with'; + const comparison: FilterArray = ['name', canonical, 'A']; + const group: FilterArray = ['and', ['a', '=', 1], ['b', '>', 2]]; + const list: FilterArray = [['a', '=', 1], ['b', '>', 2]]; + // @ts-expect-error — 'equalss' is not in the operator vocabulary. + const misspelled: FilterArray = ['status', 'equalss', 'won']; + // @ts-expect-error — a group needs at least one condition. + const empty: FilterArray = ['and']; + + expect([comparison, group, list, misspelled, empty]).toHaveLength(5); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 8cdff1cca7..22c54080dc 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -419,8 +419,16 @@ export const NormalizedFilterSchema: z.ZodType` annotation so + * the KEY SET survives inference: {@link FilterArrayOperator} is + * `keyof typeof AST_OPERATOR_MAP`, which is how the authoring type for a + * comparison node's operator position stays derived from this one table instead + * of becoming the third hand-written copy of the vocabulary (#3948 is what two + * copies cost). Lookups by a runtime `string` go through + * {@link astOperatorLowering}. */ -const AST_OPERATOR_MAP: Record = { +const AST_OPERATOR_MAP = { '=': '$eq', '==': '$eq', 'equals': '$eq', @@ -476,7 +484,19 @@ const AST_OPERATOR_MAP: Record = { 'is_not_empty': '$null', 'isempty': '$null', 'isnotempty': '$null', -}; +} satisfies Record; + +/** + * `$`-operator lowering for one infix spelling, or `undefined` when the spelling + * is not in the vocabulary. + * + * {@link AST_OPERATOR_MAP} keeps its literal key set (see its note), so it can + * no longer be indexed by an arbitrary runtime `string`. This is the one place + * that widens it back, so the widening is visible instead of scattered. + */ +function astOperatorLowering(op: string): string | undefined { + return (AST_OPERATOR_MAP as Record)[op]; +} /** * Set of valid AST comparison operators (case-insensitive). @@ -527,7 +547,7 @@ export function canonicalAstOperator(op: string): string { // the wildcards. Folding them onto `contains` would silently wrap the value in // `%…%` and change what the query means. if (lower === 'like' || lower === 'ilike') return lower; - const dollar = AST_OPERATOR_MAP[lower]; + const dollar = astOperatorLowering(lower); if (!dollar) return lower; return CANONICAL_INFIX[dollar] ?? lower; } @@ -608,7 +628,7 @@ function convertComparison(node: [string, string, unknown]): FilterCondition { return { [field]: { $null: false } } as FilterCondition; } - const mapped = AST_OPERATOR_MAP[op]; + const mapped = astOperatorLowering(op); if (mapped) { return { [field]: { [mapped]: value } } as FilterCondition; } @@ -676,6 +696,182 @@ export function parseFilterAST(filter: unknown): FilterCondition | undefined { return undefined; } +// ============================================================================ +// FilterArray — the INPUT-ONLY authoring sugar (#5158, maintainer ruling C) +// ============================================================================ + +/** + * Canonical operator spellings a {@link FilterArrayComparison} may carry. + * + * Derived from {@link AST_OPERATOR_MAP} — the same table `VALID_AST_OPERATORS` + * is derived from — so the authoring type cannot drift from the lowering the + * way two hand-written lists did in #3948. + * + * **Canonical, not exhaustive-of-what-parses.** Every door folds case before + * looking an operator up, so already-stored metadata and older authoring tools + * legitimately carry camelCase spellings (`startsWith`, `notEquals`, + * `greaterThan`) that this type does not name. {@link FilterArraySchema} + * accepts them; this type steers new producers to the canonical form. That + * split is the established pattern next door — `ViewFilterOperator` names the + * canonical vocabulary while `VIEW_FILTER_OPERATOR_ALIASES` (`ui/view.zod.ts`) + * carries the deprecated bridge — not a new convention. + */ +export type FilterArrayOperator = keyof typeof AST_OPERATOR_MAP; + +/** + * The two keywords that open a {@link FilterArrayGroup}. Matched + * case-insensitively at every door, so a field genuinely named `and` or `or` + * cannot occupy a comparison node's field position — see + * {@link FilterArraySchema}. + */ +export const FILTER_ARRAY_LOGIC_KEYWORDS = ['and', 'or'] as const; + +/** `'and' | 'or'` — see {@link FILTER_ARRAY_LOGIC_KEYWORDS}. */ +export type FilterArrayLogicKeyword = typeof FILTER_ARRAY_LOGIC_KEYWORDS[number]; + +/** + * One comparison: `[field, operator, value]`. + * + * The two-element form is real and deliberate — the null predicates take their + * direction from the operator NAME, so `['deleted_at', 'is_null']` carries no + * value to give. `convertComparison` ignores the value position for those, and + * every door accepts the short form. + */ +export type FilterArrayComparison = + | [field: string, operator: FilterArrayOperator, value: unknown] + | [field: string, operator: FilterArrayOperator]; + +/** `['and' | 'or', ...conditions]` — at least one condition, or it joins nothing. */ +export type FilterArrayGroup = + [logic: FilterArrayLogicKeyword, first: FilterArray, ...rest: FilterArray[]]; + +/** `[[…], […]]` — a bare list of conditions, combined with implicit AND. */ +export type FilterArrayList = [first: FilterArray, ...rest: FilterArray[]]; + +/** + * **Input-only** authoring sugar for a filter: the nested tuple/group array form + * that React block props (`filters={['status', '=', stage]}`), the client + * `FilterBuilder`, and the wire `$filter` face accept. + * + * ## It is sugar, and it is INPUT-only + * + * A `FilterArray` is not a storage shape and not a protocol shape. It is + * lowered to a {@link FilterCondition} at the single sink + * {@link parseFilterAST} (`@objectstack/spec/data`) the moment it arrives, and + * only the lowered `FilterCondition` travels any further. `where` on a query + * (`QuerySchema`, `data/query.zod.ts`) is a `FilterCondition` and **stays** one: + * this shape is deliberately NOT part of that union, so nothing downstream — no + * driver, no transport, no stored row — ever has to understand two filter + * dialects. `filter-array-declaration.test.ts` pins that exclusion. + * + * Why it is declared here at all: four published contracts (three READMEs, + * `llms.txt`, four skills, this package's own react-blocks prop table) have been + * teaching authors to write `FilterArray` while the protocol never declared it — + * a name with no definition, which is a pure trap for an AI author following the + * contract it was given. #5158's ruling C keeps the ergonomics and gives the + * name a definition, rather than widening the wire contract (rejected option A) + * or tearing up the published contracts (rejected option B). + * + * ## Producers, measured + * + * - `FilterBuilder` (`@objectstack/client`) — emits comparison tuples and + * `['and', ...]` groups. + * - React block props declared `FilterArray` in `ui/react-blocks.ts` + * (`ListView.filters`, `ObjectChart.filter`). + * - The wire `$filter` face — `metadata-protocol` runs {@link isFilterAST} and + * converts through {@link parseFilterAST}, or answers `400 INVALID_FILTER`. + * + * @example + * // Comparison + * const f: FilterArray = ['status', '=', 'active']; + * @example + * // Group + * const g: FilterArray = ['and', ['stage', '=', 'won'], ['amount', '>', 1000]]; + * @example + * // Bare list, implicit AND + * const l: FilterArray = [['stage', '=', 'won'], ['amount', '>', 1000]]; + * + * @see parseFilterAST — the single lowering sink; the ONLY way this shape + * becomes something the runtime stores or executes. + * @see FilterCondition — what it lowers to, and what `where` actually holds. + * @see https://github.com/objectstack-ai/objectstack/issues/5158 + */ +export type FilterArray = FilterArrayComparison | FilterArrayGroup | FilterArrayList; + +/** Field position: non-empty, and never a logic keyword (that reading is taken). */ +const FilterArrayFieldSchema = z.string().min(1).refine( + (field) => !(FILTER_ARRAY_LOGIC_KEYWORDS as readonly string[]).includes(field.toLowerCase()), + { + message: + `'and' / 'or' in the first position open a logical group, so they cannot name a field. ` + + `Write the comparison inside the group: ["and", ["field", "=", value]].`, + }, +); + +/** Operator position: the vocabulary `isFilterAST` gates on, folded the same way. */ +const FilterArrayOperatorSchema = z.string().refine( + (op) => VALID_AST_OPERATORS.has(op.toLowerCase()), + { + error: (issue) => + `Unknown filter operator '${String(issue.input)}'. Recognised operators: ` + + `${[...VALID_AST_OPERATORS].sort().join(', ')}.`, + }, +); + +/** Logic keyword position, folded case-insensitively like every door folds it. */ +const FilterArrayLogicSchema = z.string().refine( + (kw) => (FILTER_ARRAY_LOGIC_KEYWORDS as readonly string[]).includes(kw.toLowerCase()), + { message: `A logical group opens with 'and' or 'or'.` }, +); + +/** + * Zod schema for {@link FilterArray} — the authoring gate for the input-only + * sugar. Recursive, so the type above is written out by hand and this is + * annotated with it (the #4171 rule: a `z.ZodType< any >` annotation would throw + * the type away silently). Both type arguments are given (#4195) — no + * `.default()`, no `.transform()`, so input and output are the same shape. + * + * ## Relationship to `isFilterAST` + * + * {@link isFilterAST} stays the RUNTIME detector at the doors; this is the + * stricter AUTHORING gate. They share one operator vocabulary and one case + * fold, and differ in exactly TWO places — each a shape `isFilterAST` tolerates + * by accident and no measured producer emits, both pinned in + * `filter-array-declaration.test.ts` so the list cannot silently grow: + * + * | shape | `isFilterAST` | this schema | + * |---|---|---| + * | `['a', '=', 1, 2]` (trailing elements) | accepts, `convertComparison` drops the tail | rejects | + * | `['', '=', 1]` (empty field name) | accepts | rejects | + * + * An empty `[]` is refused by both, and is called out because the flat-list + * branch would otherwise swallow it: `[]` means "no filter", which is the + * absence of this shape rather than an instance of it. + * + * Nothing consumes this schema as a door predicate today. It is the declaration + * the published contracts were already citing, and the gate an authoring/publish + * lint can hold producers to. + */ +export const FilterArraySchema: z.ZodType = z.lazy(() => + z.union([ + // Comparison — three-element form, then the two-element null-predicate form. + z.tuple([FilterArrayFieldSchema, FilterArrayOperatorSchema, z.unknown()]), + z.tuple([FilterArrayFieldSchema, FilterArrayOperatorSchema]), + // Logical group: the keyword plus at least one condition. + z.tuple([FilterArrayLogicSchema, FilterArraySchema], FilterArraySchema), + // Legacy flat list of conditions, implicit AND. `.min(1)` because an empty + // array means "no filter", not "a filter that matches nothing". + z.array(FilterArraySchema).min(1), + ]).describe( + '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).' + ) +) as z.ZodType; + // ============================================================================ // Constants & Metadata // ============================================================================