diff --git a/.changeset/17320-filter-rule-array-guidance.md b/.changeset/17320-filter-rule-array-guidance.md new file mode 100644 index 0000000000..8efc475d89 --- /dev/null +++ b/.changeset/17320-filter-rule-array-guidance.md @@ -0,0 +1,58 @@ +--- +'@objectstack/spec': patch +--- + +The seven converged rule-array `filter` doors name the ViewFilterRule array form when they refuse the record form + +Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the +objectui#6206 family — `ElementDataSourceSchema.filter` (`ui/page.zod.ts`) and +the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / +`element:number` / `element:record_picker` rows of `ComponentPropsMap` +(`ui/component.zod.ts`). Each previously accepted the MongoDB-style record +(`{ status: 'active' }`), and each now refuses it — measured on the built +artifact, with exactly one issue apiece: `invalid_type` at `filter`, *"Invalid +input: expected array, received object"*, and nothing else. + +The prescription for that transition was already written down twice, in two +places a parse never reaches: every one of the seven `.describe()` strings, and +in full in the three `18.*-filter-rule-array` semantic migration entries. +Nothing bridges `.describe()` into a zod issue and this package installs no +global error map, so the one population whose metadata the convergence broke — +the authors, human and AI, who wrote the previously-legal form — received the +single sentence that does not say what to write instead. + +Each of the seven now answers that value with the new spelling, through the +zod-v4 `{ error }` param this package already uses for targeted guidance +(`shared/expression.zod.ts`, `ui/view.zod.ts`, `shared/strict-object.ts`): + +> `filter` on this `object-grid` takes the ViewFilterRule ARRAY form +> `[{ field, operator, value }, ...]`, and this value is the MongoDB-style +> record form this door took before the one-filter-orthography convergence. +> Write one rule per record key — they AND — so this filter becomes +> `[{ field: 'status', operator: 'equals', value: 'active' }]`. Legacy operator +> shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. +> Full conversion table: migration +> `element-data-source-and-object-block-filter-rule-array`. + +Following `strictObject`'s model rather than transcribing a sentence seven +times: the rule shape is read from `ViewFilterRuleSchema`'s own shape, the +canonical operator is `normalizeFilterOperator('eq')` — the same fold the door +itself runs — and the worked rewrite is computed from the author's own record, +so the example names their fields. A pin holds each door's `migration` id equal +to a real registry entry and each door's `surface` equal to the one its own +`strictObject` declaration registered. + +⛔ No accept set moves. The doors refuse exactly the shapes they refused +before, the generated `json-schema/` and `authorable-surface` artifacts are +byte-identical after the change, and the map returns `undefined` for everything +that is not a plain record — so an array author's element-level issues +(`filter.0: Invalid option: expected one of "equals"|…`) and a non-record value +(*"expected array, received string"*) still arrive in zod's own words. + +**Shipped, which is why it carries a changeset rather than `skip-changeset`.** +Measured on the built artifact after both tsup passes finished: the new message +text is present in **18** published files of `npm pack --dry-run`'s 2012, the +test-only text is present in **0** (negative control), and a pre-existing +shipped string reaches **62** as the lit control proving the scan reaches. +`src/ui/page.zod.ts` and `src/ui/component.zod.ts` are also shipped as source +by `files[]`'s `src/**/*.zod.ts`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index e61fa0f793..65eb6d491b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -17,6 +17,7 @@ import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; // shared source rather than re-spelled here (#6276). import { SortItemSchema } from '../shared/enums.zod'; import { strictObject } from '../shared/strict-object'; +import { ruleArrayFilterError } from './filter-rule-array'; import type { KeySetGuidance } from '../shared/suggestions.zod'; // [#13855] The section → field-group reference form, shared with // `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches. @@ -1850,7 +1851,12 @@ export const ElementNumberPropsSchema = lazySchema(() => strictObject({ * array, by design). The record form is refused at `filter`; the migration * prescription is the `element-number-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `element:number`', + migration: 'element-number-filter-rule-array', + }), + }).optional() .describe('Filter rules narrowing the aggregate — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` input in this map shares. The MongoDB-style record form is refused — see migration `element-number-filter-rule-array`'), format: z.enum(['number', 'currency', 'percent']).optional().describe('Number display format'), prefix: z.string().optional().describe('Prefix text (e.g. "$")'), @@ -2230,7 +2236,12 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => strictObject({ * (`ds.filter ?? props.filter`) is `ElementDataSourceSchema`'s key, not this * entry's subject. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `element:record_picker`', + migration: 'element-record-picker-filter-rule-array', + }), + }).optional() .describe('Filter rules narrowing which records the picker offers — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography the array-declared `filter` doors of this map share. The MongoDB-style record form is refused — see migration `element-record-picker-filter-rule-array`. The binding-level `dataSource.filter` wins outright when both are set'), /** * Row order (#6276). The flat shorthand for `dataSource.sort`, and the same @@ -2491,7 +2502,12 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * `filter`; the migration prescription is the * `element-data-source-and-object-block-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-grid`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), defaultFilters: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), @@ -2705,7 +2721,12 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({ * at `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-metric`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() .describe('Filter the aggregation is scoped by — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), format: z.string().optional().describe("Number format pattern (e.g. '0,0', '$0,0', '0%')"), currency: z.string().optional().describe("ISO currency code (e.g. 'USD') — enables currency formatting"), @@ -2777,7 +2798,12 @@ export const ObjectKanbanPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-kanban`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() .describe('Base query filter, handed to the wire `$filter` — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), /** * Row cap (#16503 — the spec half of objectui#8172; decision batch #68, @@ -2928,7 +2954,12 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ * `filter`; see migration * `element-data-source-and-object-block-filter-rule-array`. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-calendar`', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), /** * Row order for the fetched events — the same `SortItem` ARRAY form diff --git a/packages/spec/src/ui/filter-rule-array-guidance.test.ts b/packages/spec/src/ui/filter-rule-array-guidance.test.ts new file mode 100644 index 0000000000..b5449b73e6 --- /dev/null +++ b/packages/spec/src/ui/filter-rule-array-guidance.test.ts @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The seven converged rule-array `filter` doors name the new spelling when + * they refuse the old one. + * + * Seven doors converged on `z.array(ViewFilterRuleSchema)` (the objectui#6206 + * family) and each one refused the record form an author used to write with a + * bare `invalid_type` — "Invalid input: expected array, received object", and + * nothing else. The prescription existed in two places that a parse never + * reaches (the `.describe()` strings, the three `18.*-filter-rule-array` + * semantic entries), so the population whose metadata the convergence broke + * got the one sentence that does not say what to write instead. + * + * This file pins four things, and the last two are the ones that keep the + * message from becoming a seventh transcription of the truth: + * + * §1 every door answers the record form with the prescription; + * §2 nothing else is swallowed — an array author's element-level issues and + * a non-record value still get the ordinary message; + * §3 the `migration` id each door names is a real entry in the migration + * registry; + * §4 the `surface` each door names is the surface its own `strictObject` + * declaration registered, and the rule form in the message is + * `ViewFilterRuleSchema`'s own shape. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { ComponentPropsMap } from './component.zod'; +import { ElementDataSourceSchema } from './page.zod'; +import { ViewFilterRuleSchema, normalizeFilterOperator } from './view.zod'; +import { ruleArrayFilterError } from './filter-rule-array'; +import { strictObjectDeclarations } from '../shared/strict-object'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; + +/** The record form these doors took before the convergence. */ +const RECORD_FORM = { status: 'active' } as const; + +/** + * The seven doors, each with enough sibling props to reach a clean reading — + * the other required keys are filled so the only issue under test is `filter`. + */ +const DOORS: readonly { + readonly name: string; + readonly surface: string; + readonly migration: string; + readonly parse: (filter: unknown) => z.ZodSafeParseResult; +}[] = [ + { + name: 'ElementDataSourceSchema.filter', + surface: 'this element data source', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ElementDataSourceSchema.safeParse({ object: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-grid'].filter", + surface: 'this `object-grid`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-grid'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-metric'].filter", + surface: 'this `object-metric`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-metric'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-kanban'].filter", + surface: 'this `object-kanban`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-kanban'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['object-calendar'].filter", + surface: 'this `object-calendar`', + migration: 'element-data-source-and-object-block-filter-rule-array', + parse: (filter) => ComponentPropsMap['object-calendar'].safeParse({ objectName: 'task', filter }), + }, + { + name: "ComponentPropsMap['element:number'].filter", + surface: 'this `element:number`', + migration: 'element-number-filter-rule-array', + parse: (filter) => + ComponentPropsMap['element:number'].safeParse({ object: 'task', aggregate: 'count', filter }), + }, + { + name: "ComponentPropsMap['element:record_picker'].filter", + surface: 'this `element:record_picker`', + migration: 'element-record-picker-filter-rule-array', + parse: (filter) => ComponentPropsMap['element:record_picker'].safeParse({ object: 'task', filter }), + }, +]; + +/** The one issue raised at the `filter` key itself. */ +function filterIssue(result: z.ZodSafeParseResult) { + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const at = result.error.issues.filter((i) => i.path.join('.') === 'filter'); + expect(at).toHaveLength(1); + return at[0]!; +} + +describe('§1 the record form is refused WITH the new spelling', () => { + it.each(DOORS.map((d) => [d.name, d] as const))('%s', (_name, door) => { + const issue = filterIssue(door.parse(RECORD_FORM)); + + // Still the same refusal — this round moves what it SAYS, not what is accepted. + expect(issue.code).toBe('invalid_type'); + + // Names the new spelling: the rule-array form, and the author's own key + // carried into a worked rewrite. + expect(issue.message).toContain('[{ field, operator, value }, ...]'); + expect(issue.message).toContain("[{ field: 'status', operator: 'equals', value: 'active' }]"); + + // Names where the full conversion table lives, and which door this is. + expect(issue.message).toContain(`migration \`${door.migration}\``); + expect(issue.message).toContain(door.surface); + }); + + it('the rewrite is computed from the author own record, not a canned example', () => { + const issue = filterIssue( + ElementDataSourceSchema.safeParse({ + object: 'task', + filter: { owner: 'me', priority: 3, archived: false }, + }), + ); + expect(issue.message).toContain( + "[{ field: 'owner', operator: 'equals', value: 'me' }, " + + "{ field: 'priority', operator: 'equals', value: 3 }, " + + "{ field: 'archived', operator: 'equals', value: false }]", + ); + }); + + it('an operator-object value is not mis-prescribed as an `equals` scalar', () => { + const issue = filterIssue( + ElementDataSourceSchema.safeParse({ object: 'task', filter: { amount: { $gt: 100 } } }), + ); + expect(issue.message).toContain("{ field: 'amount', operator: …, value: … }"); + expect(issue.message).toContain('lifts that operator into `operator`'); + expect(issue.message).not.toContain("operator: 'equals', value: { "); + }); +}); + +describe('§2 nothing else is swallowed', () => { + // The door-shaped negative control: the array author whose element is wrong. + // The card's own warning — a blanket message here would overwrite exactly + // these issues, which are the ones an array author needs. + it.each(DOORS.map((d) => [d.name, d] as const))( + '%s — a bad ELEMENT still reports at `filter.0` in zod own words', + (_name, door) => { + const result = door.parse([{ field: 'status', operator: 'nope', value: 'active' }]); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const under = result.error.issues.filter((i) => i.path.join('.').startsWith('filter.')); + expect(under.length).toBeGreaterThan(0); + for (const issue of under) { + expect(issue.message).not.toContain('migration `'); + expect(issue.message).not.toContain('[{ field, operator, value }, ...]'); + } + // …and the array door itself says nothing at all here. + expect(result.error.issues.filter((i) => i.path.join('.') === 'filter')).toHaveLength(0); + }, + ); + + it('a non-record value falls through to zod own message', () => { + const issue = filterIssue(ElementDataSourceSchema.safeParse({ object: 'task', filter: 'status=active' })); + expect(issue.message).toBe('Invalid input: expected array, received string'); + }); + + it('a class instance is a different mistake and is not sent to the filter migration', () => { + const issue = filterIssue(ElementDataSourceSchema.safeParse({ object: 'task', filter: new Date() })); + expect(issue.message).not.toContain('migration `'); + }); + + it('the valid rule array still parses', () => { + const ok = ElementDataSourceSchema.safeParse({ + object: 'task', + filter: [{ field: 'status', operator: 'equals', value: 'active' }], + }); + expect(ok.success).toBe(true); + }); +}); + +describe('§3 every migration id named by a door is a real entry', () => { + const ids = new Set( + Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic.map((entry) => entry.id)), + ); + + it('the registry was actually read (lit control)', () => { + expect(ids.size).toBeGreaterThan(0); + expect(ids.has('no-such-migration-entry')).toBe(false); + }); + + it.each([...new Set(DOORS.map((d) => d.migration))])('%s', (migration) => { + expect(ids.has(migration)).toBe(true); + }); +}); + +describe('§4 the message is derived, not transcribed', () => { + it('the rule form is `ViewFilterRuleSchema` own shape', () => { + const shape = (ViewFilterRuleSchema as unknown as { _zod: { def: { shape: object } } })._zod.def.shape; + const issue = filterIssue(ElementDataSourceSchema.safeParse({ object: 'task', filter: RECORD_FORM })); + expect(issue.message).toContain(`[{ ${Object.keys(shape).join(', ')} }, ...]`); + }); + + it('the prescribed operator is the canonical fold of the equality shorthand', () => { + const issue = filterIssue(ElementDataSourceSchema.safeParse({ object: 'task', filter: RECORD_FORM })); + expect(issue.message).toContain(`operator: '${normalizeFilterOperator('eq')}'`); + }); + + it('every wired door names the surface its own strictObject declaration registered', () => { + // The doors above are all forced by now, so their enclosing declarations + // are in the store. A declaration whose `filter` answers the record form + // with this prescription must answer it with that declaration's OWN + // surface — which is what a copy-pasted eighth door would fail. + let checked = 0; + for (const { options, shape } of strictObjectDeclarations()) { + const filter = (shape as Record).filter; + if (!filter || typeof (filter as z.ZodTypeAny).safeParse !== 'function') continue; + const result = (filter as z.ZodTypeAny).safeParse(RECORD_FORM); + if (result.success) continue; + const message = result.error.issues[0]?.message ?? ''; + if (!message.includes('[{ field, operator, value }, ...]')) continue; + expect(message).toContain(options.surface); + checked += 1; + } + // Lit control: the walk really reached the wired doors. + expect(checked).toBe(DOORS.length); + }); + + it('the helper answers only the record form (unit, away from the doors)', () => { + const bare = z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ surface: 'this probe', migration: 'probe-entry' }), + }); + expect(bare.safeParse({ a: 1 }).error?.issues[0]?.message).toContain('migration `probe-entry`'); + expect(bare.safeParse(42).error?.issues[0]?.message).toBe('Invalid input: expected array, received number'); + }); +}); diff --git a/packages/spec/src/ui/filter-rule-array.ts b/packages/spec/src/ui/filter-rule-array.ts new file mode 100644 index 0000000000..9b2f92cf38 --- /dev/null +++ b/packages/spec/src/ui/filter-rule-array.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ruleArrayFilterError` — the refusal a converged rule-array `filter` door + * gives an author who wrote the record form it used to take. + * + * ## Why this exists + * + * Seven `filter` doors converged on `z.array(ViewFilterRuleSchema)` in the + * objectui#6206 family: `ElementDataSourceSchema.filter` (`page.zod.ts`) and + * the `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / + * `element:number` / `element:record_picker` rows of `ComponentPropsMap` + * (`component.zod.ts`). Each one previously accepted the MongoDB-style record + * (`{ status: 'active' }`), and each now refuses it. + * + * The prescription for that transition is written down twice already — in + * every one of the seven `.describe()` strings, and in full in the three + * `18.*-filter-rule-array` `SemanticMigration` entries. Neither reaches a + * parse: nothing bridges `.describe()` into a zod issue, and this package + * installs no global error map. So the one population these doors changed + * behaviour for — the authors, human and AI, who wrote the previously-legal + * form — received `Invalid input: expected array, received object` and nothing + * else. Their next action is a guess, and the natural second guess (an + * ObjectQL AST tuple array) earns a second bare `invalid_type`, one level + * deeper at `filter.0`. + * + * ## Why it is a helper and not a sentence + * + * `strictObject` is the model this follows, for the reason its own header + * gives: the candidate list is **read from the shape** rather than transcribed + * beside it, so the two cannot disagree. A hand-copied sentence at seven call + * sites is seven copies to drift — and a prescription that fell out of step + * with a refusal is precisely the defect this module answers. + * + * So everything the message can derive, it derives: + * + * - the rule shape `[{ field, operator, value }, ...]` is + * `Object.keys(ViewFilterRuleSchema)`'s shape, not a literal; + * - the canonical equality operator is + * `normalizeFilterOperator('eq')` — the same fold the door itself runs, so a + * renamed canonical renames itself here; + * - the worked rewrite is computed from **the author's own record**, so the + * example names their fields rather than a stranger's. + * + * What stays per-call is what carries judgement rather than transcription — + * the same split `strictObject` draws: `surface` (which door this is) and + * `migration` (which of the three entries holds this door's conversion table). + * `filter-rule-array-guidance.test.ts` holds every `migration` passed here + * equal to a real entry id in the migration registry, so that one string + * cannot rot either. + * + * ## Fall-through is deliberate + * + * The map answers **only** the record form and returns `undefined` for + * everything else, exactly as `flattenedViewOverlayFields()`'s `object` / + * `viewKind` maps do. A blanket message here would overwrite the element-level + * issues an array author needs (`filter.0: …`), which is the diagnosis this + * module exists to protect, not to replace. + */ + +import { z } from 'zod'; + +import { ViewFilterRuleSchema, normalizeFilterOperator } from './view.zod'; + +/** Per-door facts the message cannot derive. */ +export interface RuleArrayFilterErrorOptions { + /** + * The authoring surface this door belongs to, worded as `strictObject`'s own + * `surface` is (it is dropped into "… on this `object-grid`"). + */ + surface: string; + /** + * The `SemanticMigration` id whose `replacement` carries the full conversion + * table for this door. Pinned against the registry by + * `filter-rule-array-guidance.test.ts`. + */ + migration: string; +} + +/** How many of the author's own keys the worked rewrite spells out. */ +const REWRITE_KEY_BUDGET = 3; + +/** + * A plain record — the shape these doors used to take. + * + * Deliberately narrower than `typeof input === 'object'`: a `Date`, a `Map` or + * a class instance at this key is a different mistake, and answering it with + * the filter-orthography prescription would send that author to the wrong + * migration entry. + */ +function isRecordForm(input: unknown): input is Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return false; + const proto = Object.getPrototypeOf(input); + return proto === Object.prototype || proto === null; +} + +/** Render a scalar the way an author would write it back into the rule. */ +function renderValue(value: unknown): string | undefined { + if (typeof value === 'string') return `'${value}'`; + if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value); + return undefined; +} + +/** + * The declared keys of one rule, read from the schema rather than transcribed. + * + * Read on FIRST REFUSAL, never at module load: `ViewFilterRuleSchema` is a + * `lazySchema` proxy, and touching `_zod` forces its body. Under + * `OS_EAGER_SCHEMAS=1` (how `build-schemas.ts` runs) a module-load read here + * would build it while `view.zod` is still initialising — the same import-cycle + * footgun `strictObjectError` defers around, and for the same reason: the map + * is needed only when a value is rejected. + */ +function ruleKeys(): readonly string[] { + const def = (ViewFilterRuleSchema as unknown as { _zod?: { def?: { shape?: object } } })._zod?.def; + const shape = def?.shape; + return shape ? Object.keys(shape) : []; +} + +/** + * Build the `{ error }` map for one converged rule-array `filter` door. + * + * @example + * ```ts + * filter: z.array(ViewFilterRuleSchema, { + * error: ruleArrayFilterError({ + * surface: 'this `object-grid`', + * migration: 'element-data-source-and-object-block-filter-rule-array', + * }), + * }).optional().describe('…'), + * ``` + */ +export function ruleArrayFilterError(options: RuleArrayFilterErrorOptions): z.core.$ZodErrorMap { + const { surface, migration } = options; + + return (issue) => { + if (issue.code !== 'invalid_type') return undefined; + if ((issue as { expected?: string }).expected !== 'array') return undefined; + const input = issue.input; + if (!isRecordForm(input)) return undefined; + + const keys = ruleKeys(); + const ruleForm = keys.length > 0 ? `[{ ${keys.join(', ')} }, ...]` : '[{ … }, ...]'; + const equals = normalizeFilterOperator('eq'); + + const authored = Object.keys(input); + const shown = authored.slice(0, REWRITE_KEY_BUDGET); + const rules = shown.map((key) => { + const rendered = renderValue(input[key]); + return rendered === undefined + ? `{ field: '${key}', operator: …, value: … }` + : `{ field: '${key}', operator: '${equals}', value: ${rendered} }`; + }); + const ellipsis = authored.length > shown.length ? ', …' : ''; + const rewrite = rules.length > 0 ? `\`[${rules.join(', ')}${ellipsis}]\`` : `\`[]\``; + + const nested = shown.some((key) => renderValue(input[key]) === undefined); + + return ( + `\`filter\` on ${surface} takes the ViewFilterRule ARRAY form \`${ruleForm}\`, ` + + `and this value is the MongoDB-style record form this door took before the ` + + `one-filter-orthography convergence. Write one rule per record key — they AND — ` + + `so this filter becomes ${rewrite}.` + + (nested + ? ` A key whose value is an operator object (\`{ amount: { $gt: 100 } }\`) lifts that ` + + `operator into \`operator\`: \`[{ field: 'amount', operator: 'greater_than', value: 100 }]\`.` + : '') + + ` Legacy operator shorthands (\`eq\`, \`gt\`, \`notIn\`, …) are accepted and normalized on parse.` + + ` Full conversion table: migration \`${migration}\`.` + ); + }; +} diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 680ad6cc2a..3c4942e3cb 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -20,6 +20,7 @@ import { import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { ruleArrayFilterError } from './filter-rule-array'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** @@ -222,7 +223,12 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ * form is refused at `filter`; the migration prescription is the * `element-data-source-and-object-block-filter-rule-array` semantic entry. */ - filter: z.array(ViewFilterRuleSchema).optional() + filter: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this element data source', + migration: 'element-data-source-and-object-block-filter-rule-array', + }), + }).optional() .describe('Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), sort: z.array(SortItemSchema).optional().describe('Sort order'), limit: z.number().int().positive().optional().describe('Max records to display'),