From 7b7338fb006f4e1f3e3b1267f43225ffb6e10792 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:59:16 +0000 Subject: [PATCH 01/17] fix(spec)!: judge the scalar arm, the icontains comparand and defaultFilters at parse (#19514) WIP checkpoint before the build/regeneration lap. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/filter.zod.ts | 82 ++++++- ...er-icontains-comparand-refused-at-parse.ts | 65 ++++++ ....object-grid-default-filters-rule-array.ts | 63 ++++++ ...lter-rule-scalar-operator-array-refused.ts | 69 ++++++ packages/spec/src/ui/component.zod.ts | 35 ++- packages/spec/src/ui/view.zod.ts | 213 +++++++++++++++--- 6 files changed, 485 insertions(+), 42 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 4dd3e7836ec..c047ab9065d 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -4,6 +4,11 @@ import { z } from 'zod'; import { assertListComparandShapes } from './filter-comparand-shape'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './date-range-presets'; +// [#19514] The text-comparand door this package publishes for the +// case-insensitive contains operator: the discrimination `FILTER_TEXT_CASES`' +// two REJECTION rows are about, and the reason text that answers them. Called +// rather than restated so the `$` dialect and the view vocabulary judge one set. +import { isRefusedTextComparand, textComparandRefusalReason } from './filter-text-comparand'; import { OPERATOR_PREFIX_KEY_PATTERN, bannedKeyPattern } from '../shared/refinement-projection'; /** @@ -1527,6 +1532,13 @@ const PRESET_JUDGED_ORDERING_OPS: ReadonlySet = new Set(['$gt', '$gte', /** Bounded-depth guard — mirrors the engine door's own limit. */ const PRESET_WALK_MAX_DEPTH = 32; +/** + * [#19514] The `$`-dialect operator `FILTER_TEXT_CASES` writes comparand + * REJECTION rows for — the spelling the published rows' `mustMention` carries, + * named once so the judged key and the refused key cannot drift apart. + */ +const FILTER_TEXT_COMPARAND_OPERATOR = '$icontains'; + /** Plain filter STRUCTURE, as the engine door classifies it (a `Date` is a comparand). */ function isPlainFilterNode(value: unknown): value is Record { return ( @@ -1538,8 +1550,10 @@ function isPlainFilterNode(value: unknown): value is Record { } /** - * [#8793] Walk one condition node and report every bare preset name sitting in - * an ordering-comparand position. + * Walk one condition node and report every comparand this authoring door + * refuses — the bare date-range PRESET names in an ordering position (#8793), + * and the `$icontains` comparands the platform's own conformance table declares + * refused (#19514). * * Descends non-`$` keys only (operator specs and nested relations): the * `$and` / `$or` / `$not` members are re-parsed by {@link FilterConditionSchema} @@ -1547,8 +1561,40 @@ function isPlainFilterNode(value: unknown): value is Record { * descending here as well would double-report. Unrecognised `$` keys are * skipped WITHOUT descending, the engine door's own conservatism: a hole, not * a false refusal, is the right failure direction. - */ -function checkBarePresetOrderingComparands( + * + * ## Why the second arm rides this walk instead of a second refinement + * + * They ask the same question of the same positions — "what is sitting in this + * operator's comparand slot?" — and they share every boundary the walk draws: + * the depth bound, the `$`-key conservatism, and the rule that combinator + * members are judged by their own pass. A second `superRefine` would duplicate + * the descent, and the two copies would answer differently the first time one + * of those boundaries moved. One walk, one set of boundaries, `n` arms. + * + * ## The `$icontains` arm answers the TABLE, and does not read it here + * + * `FILTER_TEXT_CASES` declares two REJECTION rows for this operator (an EMPTY + * comparand and a NON-STRING one, `code: 'INVALID_FILTER'`, + * `mustMention: ['$icontains']`) and `filter-text-comparand.ts` publishes the + * discrimination those rows are about. This arm CALLS that predicate. A local + * `typeof !== 'string' || === ''` would be a second spelling of a rule the table + * owns, drifting apart the first time a row moved — which is the exact failure + * that lifted the predicate into this package. A row added to the table reaches + * this door with no edit here. + * + * ⚠️ **No absence carve-out, and that is the DIALECT's fact, not an extra row.** + * `isRefusedTextComparand` answers `true` for `undefined` and its docblock hands + * the carve-out to "a vocabulary with an 'absent' the `$` dialect does not + * have". This is that dialect: `{ name: { $icontains: undefined } }` is not an + * omitted comparand, it is `undefined` written into a comparand slot — the + * shape `FILTER_COMPARAND_TYPE_CASES` calls the mongo silent-edit worst cell. + * The view vocabulary, which DOES have an absent, carves it out on its own side. + * + * ⛔ **Scoped to the one operator the table writes rows for.** `$contains` / + * `$startsWith` / `$endsWith` / `$like` / `$ilike` have no such row and keep the + * answer they have always given; widening by analogy is the table's decision. + */ +function checkFilterConditionComparands( node: unknown, ctx: z.RefinementCtx, path: (string | number)[] = [], @@ -1564,10 +1610,20 @@ function checkBarePresetOrderingComparands( if (!hasOperatorKeys) { // Nested relation / deep equality — the schema does not re-parse these, // so the walk descends itself. - checkBarePresetOrderingComparands(value, ctx, [...path, key], depth + 1); + checkFilterConditionComparands(value, ctx, [...path, key], depth + 1); continue; } for (const [op, comparand] of Object.entries(value)) { + if (op === FILTER_TEXT_COMPARAND_OPERATOR && isRefusedTextComparand(comparand)) { + ctx.addIssue({ + code: 'custom', + path: [...path, key, op], + message: + `The ${textComparandRefusalReason(key, op, comparand)}. This is refused at ` + + `authoring time because the query path refuses it too (400 INVALID_FILTER).`, + }); + continue; + } if (PRESET_JUDGED_ORDERING_OPS.has(op) && isDateRangePresetName(comparand)) { ctx.addIssue({ code: 'custom', @@ -1709,12 +1765,16 @@ export const FilterConditionSchema: z.ZodType $or: z.array(FilterConditionSchema).optional(), $not: FilterConditionSchema.optional(), }) - // [#8793] Bare date-range preset names are refused from ordering comparands - // at the authoring door — see the § 3.35 block above for the ruling, the - // measured defect, and the ordering-only boundary. The refinement judges - // this node's own field entries; `$and` / `$or` / `$not` members re-enter - // the schema and are judged by their own pass with nested issue paths. - ).superRefine((node, ctx) => checkBarePresetOrderingComparands(node, ctx)) + // Two comparand refusals ride one walk — see its docblock for why. [#8793] + // Bare date-range preset names are refused from ordering comparands (the + // § 3.35 block above carries the ruling, the measured defect and the + // ordering-only boundary); [#19514] `$icontains` comparands are refused on + // the two shapes `FILTER_TEXT_CASES` already declares refused, so the door + // stops admitting the document its own conformance table says will 400. The + // refinement judges this node's own field entries; `$and` / `$or` / `$not` + // members re-enter the schema and are judged by their own pass with nested + // issue paths. + ).superRefine((node, ctx) => checkFilterConditionComparands(node, ctx)) ); // ============================================================================ diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts new file mode 100644 index 00000000000..1763a5869a8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// One entry for two doors on purpose: the two vocabularies spell one operator +// and the rows being answered are one pair. Splitting it would put half the +// prescription in front of an author who wrote the other spelling. +export const entry: SemanticMigration = { + id: 'filter-icontains-comparand-refused-at-parse', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect ' + + 'key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, ' + + 'dashboard and analytics filters) and the infix spelling icontains on ' + + 'ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the ' + + 'EMPTY STRING or is not a string at all', + replacement: + 'a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate ' + + 'that constrains nothing, so the repair is to DROP the condition rather than to write ' + + 'something in it. A comparand that was a number, boolean or null is written as the ' + + 'string it was meant to match: value 42 becomes value "42" only if a substring match on ' + + 'the two characters is really what was meant, and if it is not, the operator was the ' + + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + + 'and this rule says nothing about it', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. Every backend ' + + 'answers those rows. Nothing applied them at PARSE on either vocabulary, so the ' + + 'protocol declared the refusal and then admitted the document that would hit it — the ' + + 'declared-not-enforced shape ADR-0049 exists to close. ' + + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + + 'the published predicate isRefusedTextComparand and the published reason text ' + + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' + + '$endsWith, $like and $ilike have no such row and keep the answer they have always ' + + 'given, because widening by analogy is the table\'s decision and not a door\'s. ' + + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' + + 'slot is the refused non-string shape — the same reading the comparand-type door ' + + 'already takes of that cell. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. ' + + 'An empty comparand has no lossless replacement (dropping a condition changes which ' + + 'rows a view returns, which is the author\'s decision) and a non-string one has no ' + + 'honest coercion (the platform refuses to answer a query nobody wrote). The read path ' + + 'does not re-validate stored rows, so a stored filter keeps loading; what changes is ' + + 'that RE-SAVING it is refused, with the reason text three shipped consumer faces ' + + 'already show at query time. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored filters for the case-insensitive contains operator in either ' + + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + + 'and the repair is to delete it, and a non-string one means either a missing pair of ' + + 'quotes or the wrong operator. A filter whose comparand was empty has been returning ' + + 'EVERY row, not zero, so a list that looked unfiltered was unfiltered — re-check what ' + + 'the view is supposed to show. A filter whose comparand was not a string has been ' + + 'answered with INVALID_FILTER at query time on every backend, so it has never returned ' + + 'rows at all. Both refusals now arrive at the authoring path with the same reason text ' + + 'the runtime gives, so the message an author reads is the same message wherever they ' + + 'hit it.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts new file mode 100644 index 00000000000..ad50994b414 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The key the one-filter-orthography convergence did not name. Its sibling +// entry element-data-source-and-object-block-filter-rule-array says so in as +// many words — 「object-grid.defaultFilters is a different key and is not named +// by the ruling this entry records」 — so this is the entry that names it. +export const entry: SemanticMigration = { + id: 'object-grid-default-filters-rule-array', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' + + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' + + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' + + 'rules alike', + replacement: + 'the same ViewFilterRule array form its sibling filter takes — ' + + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' + + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' + + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' + + 'operator into the rule, becoming ' + + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' + + '[["owner_id", "=", "{current_user_id}"]] becomes ' + + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' + + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' + + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' + + 'is read only when filter is absent, and its own description has prescribed filter all ' + + 'along', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + + 'in the SAME role as filter — the key\'s own description says it is read only when ' + + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + + 'refusal that sink can give was reachable from a document the protocol had just ' + + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' + + 'block with one declared door and one undeclared door onto one seam. An author who put ' + + 'the record form on the fallback got a silent success receipt and a 400 at render, with ' + + 'nothing in between to tell them which of the two keys was the problem. ' + + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + + 'own ruling; the deprecation already stated in the description is unchanged and still ' + + 'says to prefer filter. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, ' + + 'for the reason its sibling gives at length: a SemanticMigration converts nothing by ' + + 'its own type, the stored-row pass replays D2 conversions only, and the read path does ' + + 'not re-validate stored rows — so a stored page carrying the record form keeps loading ' + + 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at ' + + 'the defaultFilters path, with the same conversion table the filter door gives, ' + + 'computed from the author\'s own keys. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Every object-grid node in your pages either omits defaultFilters or carries a ' + + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + + 'the conversion table and a worked rewrite built from the keys that were written, and ' + + 'an AST tuple array is refused one level in, at the first element. A grid that has been ' + + 'relying on a record-form defaultFilters was not being filtered by it — the lowering ' + + 'refused the shape — so re-check which rows the grid is supposed to show rather than ' + + 'assuming the displayed set was correct. Where both keys were authored, only filter was ' + + 'ever read: deleting defaultFilters is the whole migration.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts new file mode 100644 index 00000000000..797e4bed11c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// 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 +// an upgrade guide that quietly rewrites a shipped prescription leaves the +// reader who followed it with no trace of why their metadata now fails. +export const entry: SemanticMigration = { + id: 'view-filter-rule-scalar-operator-array-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' + + 'every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, ' + + 'Page.filterBy, a related-list filter, a lookup picker filter, and the filter and ' + + 'defaultFilters keys of the object-* page blocks', + replacement: + 'one scalar — a string, number, boolean or null. A rule written ' + + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' + + 'of a list becomes operator: "in" with the array unchanged. The list operators (in / ' + + 'not_in) and the range operator (between) are untouched and still take their arrays. ' + + 'The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched ' + + 'too: they take their direction from the operator NAME and their value position is ' + + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + + 'still an omitted value', + reason: + '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' + + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' + + 'The value key\'s own published description has declared this rule since #6227 — ' + + '「every other operator takes a scalar」 — and the refinement that implements the ' + + 'coupling returned early for every operator that is neither a list operator nor ' + + 'between, so the entire scalar class was declared and never judged. ' + + '⚠️ This REVERSES a reading recorded in the sibling entry ' + + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' + + 'which every backend answers」. Re-measured at source for this entry: the lowered node ' + + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope, and every ' + + 'in-memory matcher excludes every row for the same reason. So the earlier reading was ' + + 'the one that widened the accept set past the query path, and a stored view carrying ' + + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + + 'query path exactly and goes no further, which is the #5685 boundary this family has ' + + 'held since it was written. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + + 'two on equals has no honest single value, and picking the first is a different ' + + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' + + 'storing a filter that 400s. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + + 'then decide per rule which of the two things it meant: one value, or membership. ' + + 'os validate and os lint report each one by path with the operator, the received shape ' + + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' + + 'answered 400 INVALID_FILTER on the SQL family and excluded every row on the in-memory ' + + 'matchers — so re-check what the view is supposed to show rather than assuming the old ' + + 'result set was correct. A one-element array is the case to read closest: ' + + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + + 'today, and only the author knows which the metadata meant.', +}; diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f770c554d8b..9aa7c654900 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2714,8 +2714,39 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ }), }).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`'), + /** + * [#19514] The legacy base-filter fallback — the SAME value in the SAME role + * as `filter` above, so it carries the same declaration. + * + * Its own description has always said "read only when `filter` is absent", + * which is a statement that the two keys hold one kind of value: objectui's + * `ObjectGrid` reads this one through the same lowering sink it reads `filter` + * through, so every refusal that sink can give is reachable from a document + * that passed the protocol. While `filter` was narrowed to the rule array and + * this stayed `z.unknown()`, the block had a declared door and an undeclared + * one onto the same seam — a bare string, a number, a MongoDB-style record and + * an ObjectQL AST tuple array all parsed here, and the author's receipt said + * nothing about the 400 waiting for them. + * + * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this + * could have taken and it is a REMOVAL of an accepted shape, which needs its + * own ruling; this change only pulls the ACCEPT SET back to the one the + * consumer already honours. The deprecation stated in the description stands + * exactly where it stood — prefer `filter` — and is unchanged by this. + * + * The `{ error }` map is `filter`'s, deliberately: an author who wrote the + * record form here needs the same conversion table, computed from their own + * keys, and a second hand-written sentence at this door is the drift + * `ruleArrayFilterError` exists to prevent. Its `surface` names which key was + * written, because the message's own subject is `filter`. + */ + defaultFilters: z.array(ViewFilterRuleSchema, { + error: ruleArrayFilterError({ + surface: 'this `object-grid` (you wrote it on the `defaultFilters` fallback, which takes the same form)', + migration: 'object-grid-default-filters-rule-array', + }), + }).optional() + .describe('Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array`'), /** * Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`, * the one sort orthography every DECLARED `sort` door on this platform diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 2ecea023f2a..a20636191da 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -70,6 +70,12 @@ import { retiredKey } from '../shared/retired-key'; // docblock for the measurement and for the two routes that were not taken. import { MAX_RENDERABLE_SCALE, SCALE_UPPER_BOUND_MESSAGE } from '../shared/scale-ceiling'; import { FieldType, SelectOptionSchema } from '../data/field.zod'; +// [#19514] The text-comparand door the Filter Protocol publishes for the +// case-insensitive contains operator — the discrimination `FILTER_TEXT_CASES`' +// two REJECTION rows are about, and the reason text that answers them. Imported +// rather than restated so this vocabulary and the `$` dialect cannot drift into +// judging two different sets; the module imports nothing, so no cycle. +import { isRefusedTextComparand, textComparandRefusalReason } from '../data/filter-text-comparand'; import { BulkActionDefSchema } from './bulk-action.zod'; /** @@ -517,6 +523,34 @@ function previewFilterValue(value: unknown): string { return text.length > 40 ? `${text.slice(0, 39)}…` : text; } +/** + * The operators that take their direction from their NAME and ignore `value`. + * + * Deliberately NOT exported, where {@link VIEW_FILTER_LIST_VALUE_OPERATORS} and + * {@link VIEW_FILTER_PAIR_VALUE_OPERATORS} are. Those two exist because a + * PRODUCER has to ask the question the schema asks — `@object-ui`'s filter + * builder decides `isMultiOperator` and would otherwise keep its own list. This + * set answers the opposite question ("may I skip the value check?"), which only + * the check below asks; publishing it would enlarge the package's public face to + * carry a fact nothing outside this file needs. The vocabulary itself is + * declared once, in {@link VIEW_FILTER_OPERATORS}, and this is a subset of it. + */ +const VIEW_FILTER_VALUELESS_OPERATORS = [ + 'is_empty', 'is_not_empty', 'is_null', 'is_not_null', +] as const satisfies readonly ViewFilterOperator[]; + +/** + * The one operator this vocabulary spells for which `FILTER_TEXT_CASES` writes + * comparand REJECTION rows — the infix twin of the `$` dialect's `$icontains` + * (the `AST_OPERATOR_MAP` row that made them one capability, not two spellings). + * + * A named constant rather than a literal at the comparison, so the coupling + * between "the operator this door judges" and "the spelling the refusal names" + * is one declaration. Private for the same reason + * {@link VIEW_FILTER_VALUELESS_OPERATORS} is. + */ +const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOperator; + /** * [#6227] `value` must have the shape the rule's OPERATOR can execute. * @@ -534,33 +568,56 @@ function previewFilterValue(value: unknown): string { * module docblock names this schema as the reachable authoring source of the * defect. * - * ## Why this mirrors the runtime gate EXACTLY, and refuses to go further - * - * The checks below are `assertListComparandShapes`' three constraints, one for - * one: `$in`/`$nin` must be an array, `$between` must be a 2-array. Nothing else - * is judged here, deliberately — #5685 already ruled on the opposite error, where - * `FieldOperatorsSchema` declared `$gt` as `number | Date | FieldReference` while - * every first-party producer put an ISO STRING there; the schema was ruled the - * wrong side and widened to match the runtime. A publish-time gate refusing more - * than the query path refuses would re-create that mismatch pointing the other - * way, and would reject stored metadata that executes correctly today. - * Specifically NOT refused, because the runtime does not refuse them: + * ## Why this mirrors the query path EXACTLY, and refuses to go further + * + * The first two checks below are `assertListComparandShapes`' constraints, one + * for one: `$in`/`$nin` must be an array, `$between` must be a 2-array. The + * third — a SCALAR operator handed an array — is `driver-sql`'s + * `assertCompilableComparand` scalar arm, and it is here for the same reason the + * other two are: the query path refuses it, so refusing it at authoring time + * moves the refusal to the moment the author can still act on it. Nothing beyond + * those is judged, deliberately — #5685 already ruled on the opposite error, + * where `FieldOperatorsSchema` declared `$gt` as `number | Date | FieldReference` + * while every first-party producer put an ISO STRING there; the schema was ruled + * the wrong side and widened to match the runtime. A publish-time gate refusing + * more than the query path refuses would re-create that mismatch pointing the + * other way, and would reject stored metadata that executes correctly today. + * + * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said + * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality + * comparand, which every backend answers". Re-measured at source: the lowered + * `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which + * calls `assertCompilableComparand(column, '=', value)`; `'='` is in that file's + * `SCALAR_COMPARAND_OPERATORS`, `isBindableComparand(['a'])` is `false` (an array + * is none of the six accepted comparand types — `isAcceptedFilterComparand`, + * `filter-comparand-type.ts`), and the comparand is refused with the withheld + * `INVALID_FILTER` / 400 envelope. Every in-memory matcher excludes every row for + * the same reason. So the ORIGINAL reading was the one that widened the accept + * set past the query path; this arm pulls it back to what `value`'s own + * `.describe()` has declared all along — 「every other operator takes a scalar」. + * Direction set by objectui#9050's ruling C′ (「the differences are the + * protocol's to close」); prescription registered as the ADR-0087 entry + * `view-filter-rule-scalar-operator-array-refused`. + * + * Specifically NOT refused, because the query path does not refuse them: * * - **`in: []` / `not_in: []`.** An empty list is a legitimate declared predicate * — "matches nothing" / "matches everything" — and the runtime gate says so in * as many words. Arity is not this check's business for membership; only "is it * a list at all". - * - **A scalar operator carrying an array** (`equals: ['a','b']`). `equals` - * lowers to a bare `{ field: value }` deep-equality comparand - * (`convertComparison`), which every backend answers. * - **A string operator carrying a number** (`contains: 5`). Lowers to - * `$contains: 5`; no backend refuses it. - * - **A unary operator carrying a value** (`is_empty: ''`). The null predicates - * take their direction from the operator NAME — `convertComparison` maps them - * to `{ $null: true|false }` and ignores the value position entirely — and the - * ObjectUI client deliberately sends a truthy PLACEHOLDER value for both - * `isnull` and `isnotnull`. Refusing it would break a live first-party producer - * to enforce nothing. + * `$contains: 5`; no backend refuses it. `icontains` is the ONE exception and + * it is not an analogy — `FILTER_TEXT_CASES` declares that comparand + * refused as data, and {@link checkViewFilterRuleTextComparand} below answers + * those rows and only those rows. + * - **A unary operator carrying a value** (`is_empty: ''`, and `is_empty: []`). + * The null predicates take their direction from the operator NAME — + * `convertComparison` maps them to `{ $null: true|false }` and ignores the + * value position entirely — and the ObjectUI client deliberately sends a + * truthy PLACEHOLDER value for both `isnull` and `isnotnull`. Refusing it would + * break a live first-party producer to enforce nothing, which is why the scalar + * arm skips them explicitly rather than by accident. `value`'s own + * `.describe()` carves them out in the same words. * * ## Why `superRefine` and not `z.discriminatedUnion` (measured, not assumed) * @@ -630,16 +687,102 @@ function checkViewFilterRuleValueShape( return; } - if (!isPair) return; - if (Array.isArray(value) && value.length === 2) return; + if (isPair) { + if (Array.isArray(value) && value.length === 2) return; + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `Operator "${operator}" on field "${field}" requires a [min, max] value array. ` + + `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). ` + + `A range needs exactly two bounds, in order. This is refused at authoring time ` + + `because the query path refuses it too (400 INVALID_FILTER).`, + }); + return; + } + + // Everything left takes a SCALAR — `value`'s own `.describe()` has said so + // since #6227 and nothing judged it, so the whole class rode through. The two + // carve-outs are the ones the query path itself makes: an ABSENT value (the + // key is optional, and unary operators never carry one) and the valueless + // operators, whose `value` position is discarded by `convertComparison`. + if (value === undefined) return; + if ((VIEW_FILTER_VALUELESS_OPERATORS as readonly string[]).includes(operator)) return; + if (!Array.isArray(value)) return; ctx.addIssue({ code: 'custom', path: ['value'], message: - `Operator "${operator}" on field "${field}" requires a [min, max] value array. ` + `Operator "${operator}" on field "${field}" requires a SCALAR value. ` + `Received ${describeFilterValue(value)} (${previewFilterValue(value)}). ` - + `A range needs exactly two bounds, in order. This is refused at authoring time ` - + `because the query path refuses it too (400 INVALID_FILTER).`, + + `Only "${VIEW_FILTER_LIST_VALUE_OPERATORS.join('" / "')}" take a list and only ` + + `"${VIEW_FILTER_PAIR_VALUE_OPERATORS.join('" / "')}" takes a [min, max] range — write ` + + `${value.length > 0 ? previewFilterValue(value[0]) : 'the value to compare against'} ` + + `to compare against one value, or use "${VIEW_FILTER_LIST_VALUE_OPERATORS[0]}" to test ` + + `membership of the list. This is refused at authoring time because the query path ` + + `refuses it too (400 INVALID_FILTER).`, + }); +} + +/** + * [#19514] `icontains` takes the comparand `FILTER_TEXT_CASES` declares, + * on the one operator that table writes a row for. + * + * ## The half this answers, and the half it leaves alone + * + * `@objectstack/spec/data` publishes two REJECTION rows for the + * case-insensitive contains operator — an EMPTY comparand and a NON-STRING one, + * each `code: 'INVALID_FILTER'`, `mustMention: ['$icontains']` — and publishes + * the discrimination those rows are about as {@link isRefusedTextComparand}, + * with the author-facing half as {@link textComparandRefusalReason}. Three + * objectui faces already seat that reason in their own envelopes. + * + * ⭐ This arm CALLS the predicate rather than restating it. A hand-written + * `typeof value !== 'string' || value === ''` here would be a second spelling of + * a rule the table owns, and the two would drift the first time a row moved — + * which is the failure the predicate was lifted into spec to end. A row added to + * the table therefore reaches this door without an edit here. + * + * ## Three carve-outs, each the caller's own and none of them a new row + * + * 1. **ABSENCE.** `isRefusedTextComparand(undefined)` answers `true`, and its + * docblock says in as many words that a vocabulary with an "absent" must test + * for it BEFORE asking. A view rule is exactly that vocabulary — `value` is + * optional on every rule — so an omitted comparand is left to whatever judges + * absence, and the table says nothing about it. + * 2. **ARRAYS** are {@link checkViewFilterRuleValueShape}'s business. One defect, + * one issue: an author who wrote `icontains: ['a']` is told about the SHAPE, + * which is what they have to fix first. + * 3. **THE SIBLING OPERATORS.** `contains` / `starts_with` / `ends_with` have no + * row in the table and keep the answer they have always given. Widening by + * analogy is the table's decision to make, never this door's. + * + * ## The `$` twin is named here, not in the contract half + * + * `textComparandRefusalReason` names the spelling that ARRIVED — `icontains` + * from this vocabulary — and deliberately does not substitute the `$` dialect's + * `$icontains` for it, because a view author cannot write that key. Naming the + * wire operator is the FACE's job, and this tail does it: that is where the + * refusal the author will hit at query time is spelled, and it is what the + * published rows' `mustMention` is written in. + */ +function checkViewFilterRuleTextComparand( + rule: { field?: unknown; operator?: unknown; value?: unknown }, + ctx: z.RefinementCtx, +): void { + if (rule.operator !== VIEW_FILTER_TEXT_COMPARAND_OPERATOR) return; + const value = rule.value; + if (value === undefined) return; + if (Array.isArray(value)) return; + if (!isRefusedTextComparand(value)) return; + const field = typeof rule.field === 'string' ? rule.field : ''; + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: + `The ${textComparandRefusalReason(field, VIEW_FILTER_TEXT_COMPARAND_OPERATOR, value)}. ` + + `This rule lowers to the wire operator "$icontains", where the query path refuses the ` + + `same comparand (400 INVALID_FILTER).`, }); } @@ -712,16 +855,28 @@ export const ViewFilterRuleSchema = lazySchema(() => strictObject({ * Filter value (optional for unary operators like is_empty, is_null). * * The accepted SHAPE is coupled to `operator` by - * {@link checkViewFilterRuleValueShape} (#6227). + * {@link checkViewFilterRuleValueShape} (#6227, scalar arm #19514), and the + * `icontains` COMPARAND by {@link checkViewFilterRuleTextComparand} (#19514). */ value: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) .optional().describe( 'Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an ' + 'array (any length, including []), `between` takes exactly [min, max], every other ' + 'operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / ' - + 'is_not_null) take their direction from the operator name and ignore this key.', + + 'is_not_null) take their direction from the operator name and ignore this key. ' + + 'One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY ' + + 'STRING, the comparand the Filter Protocol conformance table declares for it — an ' + + 'empty comparand constrains nothing and a non-string one would answer a query nobody ' + + 'wrote, and both are refused at the query path too.', ), -}).superRefine(checkViewFilterRuleValueShape).describe('View filter rule')); +}).superRefine((rule, ctx) => { + // ONE refinement calling two checks, rather than two chained `.superRefine`s: + // zod runs every check in the chain even after one has added an issue, so a + // chain would report an `icontains` array TWICE — once for its shape and once + // for its comparand. The order is the order an author fixes them in. + checkViewFilterRuleValueShape(rule, ctx); + checkViewFilterRuleTextComparand(rule, ctx); +}).describe('View filter rule')); export type ViewFilterRule = z.input; /** Post-parse shape of {@link ViewFilterRule} — defaults applied, transforms run (ADR-0122). */ From b13d4e450c2b0be82c3f8e9cf147f3ef34cd916d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:10:42 +0000 Subject: [PATCH 02/17] test(spec): pin all three narrowings in both directions, driven by the table (#19514) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 34 +- content/docs/references/ui/page.mdx | 4 +- content/docs/references/ui/view.mdx | 10 +- .../data/filter-icontains-parse-door.test.ts | 291 ++++++++++++++++++ packages/spec/src/migrations/registry.ts | 185 +++++++++++ ...nt-object-grid-default-filters.pin.test.ts | 144 +++++++++ .../ui/view-filter-rule-value-shape.test.ts | 144 ++++++++- 7 files changed, 776 insertions(+), 36 deletions(-) create mode 100644 packages/spec/src/data/filter-icontains-parse-door.test.ts create mode 100644 packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 7a051b848b3..9219cdc48b9 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -189,7 +189,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementNumberProps.aria` @@ -231,7 +231,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementRecordPickerProps.sort[number]` @@ -327,7 +327,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectCalendarProps.sort[number]` @@ -463,7 +463,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectGanttProps.sort[number]` @@ -523,7 +523,7 @@ Sort field and direction pair | **columns** | `any[]` | optional | Columns: field names or column definition objects | | **fields** | `any[]` | optional | Field list fallback used when `columns` is absent | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | 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** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` | +| **defaultFilters** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Legacy base-filter fallback, read only when `filter` is absent — the SAME ViewFilterRule array form `[{ field, operator, value }, ...]` as `filter`, lowered through the same sink. Prefer `filter`. The MongoDB-style record form, a bare string and an ObjectQL AST tuple array are refused — see migration `object-grid-default-filters-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] } & Record` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm's `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated | @@ -563,7 +563,17 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | + +### Nested Shape: `ObjectGridProps.defaultFilters[number]` + +View filter rule + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field name to filter on | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectGridProps.sort[number]` @@ -637,7 +647,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectKanbanProps.navigation` @@ -707,7 +717,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectMapProps.sort[number]` @@ -791,7 +801,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- @@ -838,7 +848,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectTimelineProps.sort[number]` @@ -914,7 +924,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectTreeProps.tree` @@ -1405,7 +1415,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `RecordRelatedListProps.add` diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 9f929e20688..98811cc478c 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -41,7 +41,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementDataSource.sort[number]` @@ -114,7 +114,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `InterfacePageConfig.appearance` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index b48d558ff29..374244f857f 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -317,7 +317,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- @@ -896,7 +896,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ListView.userFilters` @@ -1308,7 +1308,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectListView.selection` @@ -2065,7 +2065,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Allowed Values: `ViewFilterRule.operator` @@ -2515,7 +2515,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- diff --git a/packages/spec/src/data/filter-icontains-parse-door.test.ts b/packages/spec/src/data/filter-icontains-parse-door.test.ts new file mode 100644 index 00000000000..a25397aeeb1 --- /dev/null +++ b/packages/spec/src/data/filter-icontains-parse-door.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19514] The two comparands `FILTER_TEXT_CASES` declares REFUSED for the + * case-insensitive contains operator are refused AT PARSE, on both authoring + * vocabularies. + * + * `filter-text-conformance.test.ts` proves the table is internally honest and + * `filter-text-comparand.test.ts` proves the published predicate answers it. + * Neither of those reaches a SCHEMA: until this round the platform declared the + * two refusals as data, every backend answered them, and both authoring doors + * admitted the document anyway — `FilterConditionSchema` and + * `ViewFilterRuleSchema` both said `success: true` for an empty comparand and + * for a numeric one. That is the declared-not-enforced shape ADR-0049 closes. + * + * ## What these pins are for, and what would make them worthless + * + * §1 and §2 pin each door in BOTH directions — the newly-refused comparand + * REFUSES, and the comparand that must keep working ACCEPTS. A one-directional + * pin cannot tell a working arm from a door that refuses everything, and an + * inert door passes every ACCEPT-only suite ever written. Each `REFUSE` block + * therefore also carries an ENVELOPE control: a nearby input refused for an + * unrelated reason, proving the door is reachable and that the refusal being + * read is the one under test. + * + * §3 is the pin the derivation is FOR: the doors are driven from + * `FILTER_TEXT_CASES` itself, so a row added to the table arrives here without + * an edit, and a door that stopped following the table goes red naming the row + * it dropped. A transcribed list here would pass while the doors drifted, which + * is exactly the failure the predicate was lifted into this package to end. + */ + +import { describe, expect, it } from 'vitest'; + +import { + FILTER_TEXT_CASES, + type FilterTextCase, + type FilterTextRejectionCase, +} from './filter-text-conformance'; +import { FilterConditionSchema } from './filter.zod'; +import { ViewFilterRuleSchema } from '../ui/view.zod'; + +/** The operator under test, in the two spellings that can ARRIVE. */ +const DOLLAR_SPELLING = '$icontains'; +const INFIX_SPELLING = 'icontains'; + +const isRejection = (c: FilterTextCase): c is FilterTextRejectionCase => + 'expectRejection' in c && c.expectRejection === true; + +/** Every (field, operator, comparand) triple a case's filter carries. */ +function comparands(c: FilterTextCase): Array<{ field: string; operator: string; target: unknown }> { + const out: Array<{ field: string; operator: string; target: unknown }> = []; + for (const [field, ops] of Object.entries(c.filter as Record>)) { + if (typeof ops !== 'object' || ops === null) continue; + for (const [operator, target] of Object.entries(ops)) out.push({ field, operator, target }); + } + return out; +} + +/** The rows the table declares refused FOR THIS OPERATOR, in its own dialect. */ +const TABLE_ROWS = FILTER_TEXT_CASES.filter( + (c): c is FilterTextRejectionCase => + isRejection(c) && comparands(c).some(({ operator }) => operator === DOLLAR_SPELLING), +); + +/** The issue raised at a given path, or a failure naming what was raised instead. */ +function issueAt( + result: { success: boolean; error?: { issues: readonly { path: PropertyKey[]; message: string }[] } }, + path: string, +) { + expect(result.success).toBe(false); + const issues = (result.error?.issues ?? []).filter((i) => i.path.join('.') === path); + expect(issues.map((i) => i.message).join(' | ')).not.toBe(''); + expect(issues).toHaveLength(1); + return issues[0]!; +} + +// --------------------------------------------------------------------------- +// §1 the `$` dialect door — FilterConditionSchema +// --------------------------------------------------------------------------- + +describe('#19514 §1 — the $ dialect refuses what the table declares refused', () => { + it.each([ + ['the EMPTY comparand', '', 'EMPTY STRING'], + ['a NUMBER comparand', 42, 'not a string'], + ['a BOOLEAN comparand', true, 'not a string'], + ['a NULL comparand', null, 'not a string'], + ])('refuses %s', (_label, target, marker) => { + const issue = issueAt( + FilterConditionSchema.safeParse({ name: { [DOLLAR_SPELLING]: target } }), + `name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain(marker); + // The refusal names the spelling that ARRIVED, and the declared code. + expect(issue.message).toContain(`on operator '${DOLLAR_SPELLING}'`); + expect(issue.message).toContain('INVALID_FILTER'); + }); + + it('refuses inside a $and member, at the member own path', () => { + const issue = issueAt( + FilterConditionSchema.safeParse({ $and: [{ name: { [DOLLAR_SPELLING]: '' } }] }), + `$and.0.name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain('EMPTY STRING'); + }); + + it('refuses inside a nested relation, at the nested path', () => { + const issue = issueAt( + FilterConditionSchema.safeParse({ owner: { profile: { name: { [DOLLAR_SPELLING]: 42 } } } }), + `owner.profile.name.${DOLLAR_SPELLING}`, + ); + expect(issue.message).toContain('not a string'); + }); + + it('ENVELOPE CONTROL — this door refuses an unrelated thing, so it is reachable', () => { + // A bare date-range preset in an ordering comparand (#8793). If this reads + // ACCEPT the door is not running at all and every REFUSE above is a phantom. + const issue = issueAt( + FilterConditionSchema.safeParse({ created: { $gt: 'last_7_days' } }), + 'created.$gt', + ); + expect(issue.message).toContain('PRESET'); + expect(issue.message).not.toContain('INVALID_FILTER'); + }); + + it.each([ + ['a non-empty string comparand', { name: { [DOLLAR_SPELLING]: 'acme' } }], + ['a single space, which IS a substring', { name: { [DOLLAR_SPELLING]: ' ' } }], + ['the case-SENSITIVE sibling, empty', { name: { $contains: '' } }], + ['the case-SENSITIVE sibling, numeric', { name: { $contains: 42 } }], + ['$startsWith, empty — no row in the table', { name: { $startsWith: '' } }], + ['$endsWith, numeric — no row in the table', { name: { $endsWith: 42 } }], + ['$ilike, empty — a different operator family', { name: { $ilike: '' } }], + ['an ordinary equality condition', { name: 'acme' }], + ['the AND identity', { $and: [] }], + ])('NEGATIVE CONTROL — still accepts %s', (_label, filter) => { + expect(FilterConditionSchema.safeParse(filter).success).toBe(true); + }); + + it('⛔ the sibling operators are NOT widened by analogy — the table decides that', () => { + // Stated as its own assertion because "we did not do X" is invisible in a + // suite otherwise, and widening by analogy is the failure this scope note + // exists to prevent. + for (const op of ['$contains', '$startsWith', '$endsWith', '$like', '$ilike']) { + expect(FilterConditionSchema.safeParse({ name: { [op]: '' } }).success, op).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// §2 the infix/view dialect door — ViewFilterRuleSchema +// --------------------------------------------------------------------------- + +describe('#19514 §2 — the view vocabulary refuses the same two comparands', () => { + const rule = (value?: unknown) => + ViewFilterRuleSchema.safeParse( + value === undefined + ? { field: 'name', operator: INFIX_SPELLING } + : { field: 'name', operator: INFIX_SPELLING, value }, + ); + + it.each([ + ['the EMPTY comparand', '', 'EMPTY STRING'], + ['a NUMBER comparand', 42, 'not a string'], + ['a BOOLEAN comparand', true, 'not a string'], + ['a NULL comparand', null, 'not a string'], + ])('refuses %s', (_label, value, marker) => { + const issue = issueAt(rule(value), 'value'); + expect(issue.message).toContain(marker); + // Names the spelling a VIEW author can actually write… + expect(issue.message).toContain(`on operator '${INFIX_SPELLING}'`); + // …and names the wire operator in its own tail, which is the face's job: + // the contract half deliberately does not substitute a `$` key for what + // arrived, because a view author cannot write one. + expect(issue.message).toContain(`"${DOLLAR_SPELLING}"`); + expect(issue.message).toContain('400 INVALID_FILTER'); + }); + + it('carries no internal tracker id — the reader of this string cannot open one', () => { + expect(issueAt(rule(''), 'value').message).not.toMatch(/(? { + // No `field`. If this reads ACCEPT the door is inert. + const result = ViewFilterRuleSchema.safeParse({ operator: INFIX_SPELLING, value: '' }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + expect(result.error.issues.some((i) => i.path.join('.') === 'field')).toBe(true); + }); + + it.each([ + ['a non-empty string comparand', 'acme'], + ['a single space, which IS a substring', ' '], + ['the digit-string an author who meant 42 writes', '42'], + ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { + expect(rule(value).success).toBe(true); + }); + + it('ABSENCE is left unjudged — this vocabulary HAS an absent, and no row is about it', () => { + // `isRefusedTextComparand(undefined)` answers TRUE, and its docblock hands + // the carve-out to callers with an "absent". `value` is optional on every + // view rule, so an omitted comparand is not a comparand the table judged. + expect(rule().success).toBe(true); + }); + + it('an ARRAY is the SHAPE arm defect, reported once and with the shape wording', () => { + // One defect, one issue. The author has to fix the shape first, so that is + // what the message must be about. + const issue = issueAt(rule(['a']), 'value'); + expect(issue.message).toContain('requires a SCALAR value'); + expect(issue.message).not.toContain('EMPTY STRING'); + expect(issue.message).not.toContain('not a string'); + }); + + it('⛔ the sibling operators are NOT widened by analogy here either', () => { + for (const operator of ['contains', 'not_contains', 'starts_with', 'ends_with']) { + const emptyOne = ViewFilterRuleSchema.safeParse({ field: 'name', operator, value: '' }); + const numericOne = ViewFilterRuleSchema.safeParse({ field: 'name', operator, value: 42 }); + expect(emptyOne.success, `${operator} + empty`).toBe(true); + expect(numericOne.success, `${operator} + number`).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// §3 both doors are DRIVEN BY the table, not by a copy of it +// --------------------------------------------------------------------------- + +describe('#19514 §3 — a row added to FILTER_TEXT_CASES reaches both doors', () => { + it('the table still carries rows for this operator, so this suite is not vacuous', () => { + expect(TABLE_ROWS.length).toBeGreaterThan(0); + }); + + it.each(TABLE_ROWS.map((c) => [c.name, c] as const))( + '%s — the $ dialect door refuses the row own filter', + (_name, row) => { + const result = FilterConditionSchema.safeParse(row.filter); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + // The row declares what the refusal must NAME; the door's message is the + // published reason text, which carries the same tokens. + const joined = result.error.issues.map((i) => i.message).join(' '); + for (const token of row.mustMention) expect(joined, token).toContain(token); + expect(joined).toContain(row.code); + }, + ); + + it.each(TABLE_ROWS.map((c) => [c.name, c] as const))( + '%s — the view door refuses the same comparand under the infix spelling', + (_name, row) => { + const { field, target } = comparands(row).find(({ operator }) => operator === DOLLAR_SPELLING)!; + const result = ViewFilterRuleSchema.safeParse({ field, operator: INFIX_SPELLING, value: target }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const joined = result.error.issues.map((i) => i.message).join(' '); + // `mustMention` is spelled in the `$` dialect because the rows' filters + // are. The view face names the `$` twin in its TAIL, so every token the + // row requires is present here too — via a different sentence, on purpose. + for (const token of row.mustMention) expect(joined, token).toContain(token); + expect(joined).toContain(row.code); + }, + ); + + it('the ROWS-verdict cases of the table are all still ACCEPTED by the $ door', () => { + // The other direction of the same derivation: every case the table expects + // to be EVALUATED must pass the authoring door, or the door is refusing the + // platform's own conformance corpus. This is the assertion that goes red if + // a future arm over-reaches. + const rows = FILTER_TEXT_CASES.filter((c) => !isRejection(c)); + expect(rows.length).toBeGreaterThan(0); + for (const c of rows) { + expect(FilterConditionSchema.safeParse(c.filter).success, c.name).toBe(true); + } + }); + + it('the RETIRED-operator rejections are a different door and are NOT answered here', () => { + // `$regex` / `$options` rows are refused because the OPERATOR is retired and + // their comparands are ordinary non-empty strings. The comparand door must + // stay silent about them, or it reports the wrong repair. + const retired = FILTER_TEXT_CASES.filter( + (c) => isRejection(c) && !comparands(c).some(({ operator }) => operator === DOLLAR_SPELLING), + ); + expect(retired.length).toBeGreaterThan(0); + for (const c of retired) { + const result = FilterConditionSchema.safeParse(c.filter); + const joined = result.success ? '' : result.error.issues.map((i) => i.message).join(' '); + expect(joined, c.name).not.toContain('EMPTY STRING'); + expect(joined, c.name).not.toContain('not a string'); + } + }); +}); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 213a29821f6..8e897c8e970 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8546,6 +8546,67 @@ const step18: MigrationStep = { + 'authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry\'s transition ' + 'and are worth sweeping in the same pass.', }, + // One entry for two doors on purpose: the two vocabularies spell one operator + // and the rows being answered are one pair. Splitting it would put half the + // prescription in front of an author who wrote the other spelling. + { + id: 'filter-icontains-comparand-refused-at-parse', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the case-insensitive contains comparand, in BOTH authoring vocabularies — the $ dialect ' + + 'key $icontains inside FilterConditionSchema (query where clauses, read-scope rules, ' + + 'dashboard and analytics filters) and the infix spelling icontains on ' + + 'ViewFilterRuleSchema (view, tab, page and block filters) — where the comparand is the ' + + 'EMPTY STRING or is not a string at all', + replacement: + 'a NON-EMPTY STRING, or no condition at all. A comparand that was empty is a predicate ' + + 'that constrains nothing, so the repair is to DROP the condition rather than to write ' + + 'something in it. A comparand that was a number, boolean or null is written as the ' + + 'string it was meant to match: value 42 becomes value "42" only if a substring match on ' + + 'the two characters is really what was meant, and if it is not, the operator was the ' + + 'wrong one. On a view rule an OMITTED value is untouched — absence is not a comparand ' + + 'and this rule says nothing about it', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. Every backend ' + + 'answers those rows. Nothing applied them at PARSE on either vocabulary, so the ' + + 'protocol declared the refusal and then admitted the document that would hit it — the ' + + 'declared-not-enforced shape ADR-0049 exists to close. ' + + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' + + 'the published predicate isRefusedTextComparand and the published reason text ' + + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' + + '$endsWith, $like and $ilike have no such row and keep the answer they have always ' + + 'given, because widening by analogy is the table\'s decision and not a door\'s. ' + + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' + + 'slot is the refused non-string shape — the same reading the comparand-type door ' + + 'already takes of that cell. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion. ' + + 'An empty comparand has no lossless replacement (dropping a condition changes which ' + + 'rows a view returns, which is the author\'s decision) and a non-string one has no ' + + 'honest coercion (the platform refuses to answer a query nobody wrote). The read path ' + + 'does not re-validate stored rows, so a stored filter keeps loading; what changes is ' + + 'that RE-SAVING it is refused, with the reason text three shipped consumer faces ' + + 'already show at query time. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored filters for the case-insensitive contains operator in either ' + + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + + 'and the repair is to delete it, and a non-string one means either a missing pair of ' + + 'quotes or the wrong operator. A filter whose comparand was empty has been returning ' + + 'EVERY row, not zero, so a list that looked unfiltered was unfiltered — re-check what ' + + 'the view is supposed to show. A filter whose comparand was not a string has been ' + + 'answered with INVALID_FILTER at query time on every backend, so it has never returned ' + + 'rows at all. Both refusals now arrive at the authoring path with the same reason text ' + + 'the runtime gives, so the message an author reads is the same message wherever they ' + + 'hit it.', + }, { id: 'filter-preset-ordering-comparand-refused', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -10120,6 +10181,65 @@ const step18: MigrationStep = { + '`registry-inputs-spec-parity.test.ts` becomes deletable, which is what closes ' + 'objectui#6207.', }, + // The key the one-filter-orthography convergence did not name. Its sibling + // entry element-data-source-and-object-block-filter-rule-array says so in as + // many words — 「object-grid.defaultFilters is a different key and is not named + // by the ruling this entry records」 — so this is the entry that names it. + { + id: 'object-grid-default-filters-rule-array', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'the object-grid page block\'s defaultFilters property — the legacy base-filter fallback ' + + 'in ComponentPropsMap, which was z.unknown and therefore accepted a bare string, a ' + + 'number, a MongoDB-style record, an ObjectQL AST tuple array and a list of malformed ' + + 'rules alike', + replacement: + 'the same ViewFilterRule array form its sibling filter takes — ' + + '[{ field, operator, value }, ...]. A record-form fallback { status: "active" } becomes ' + + '[{ field: "status", operator: "equals", value: "active" }] and several record keys ' + + 'become several rules, which AND; an operator object { amount: { $gt: 100 } } lifts the ' + + 'operator into the rule, becoming ' + + '[{ field: "amount", operator: "greater_than", value: 100 }]; an AST tuple array ' + + '[["owner_id", "=", "{current_user_id}"]] becomes ' + + '[{ field: "owner_id", operator: "equals", value: "{current_user_id}" }], value ' + + 'placeholders and date macros unchanged. Legacy operator shorthands are accepted and ' + + 'normalized on parse. Better still, write the rules on filter and delete this key: it ' + + 'is read only when filter is absent, and its own description has prescribed filter all ' + + 'along', + reason: + '#19514, out of objectui#9050 ruling C-prime (maintainer 2026-09-20, verbatim, ' + + 'untranslated): 「the differences are the protocol\'s to close」. This is the SAME value ' + + 'in the SAME role as filter — the key\'s own description says it is read only when ' + + 'filter is absent — and the consumer reads it through the SAME lowering sink, so every ' + + 'refusal that sink can give was reachable from a document the protocol had just ' + + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' + + 'block with one declared door and one undeclared door onto one seam. An author who put ' + + 'the record form on the fallback got a silent success receipt and a 400 at render, with ' + + 'nothing in between to tell them which of the two keys was the problem. ' + + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + + 'own ruling; the deprecation already stated in the description is unchanged and still ' + + 'says to prefer filter. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, ' + + 'for the reason its sibling gives at length: a SemanticMigration converts nothing by ' + + 'its own type, the stored-row pass replays D2 conversions only, and the read path does ' + + 'not re-validate stored rows — so a stored page carrying the record form keeps loading ' + + 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at ' + + 'the defaultFilters path, with the same conversion table the filter door gives, ' + + 'computed from the author\'s own keys. ADR-0049 / ADR-0087.', + acceptanceCriteria: + 'Every object-grid node in your pages either omits defaultFilters or carries a ' + + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + + 'the conversion table and a worked rewrite built from the keys that were written, and ' + + 'an AST tuple array is refused one level in, at the first element. A grid that has been ' + + 'relying on a record-form defaultFilters was not being filtered by it — the lowering ' + + 'refused the shape — so re-check which rows the grid is supposed to show rather than ' + + 'assuming the displayed set was correct. Where both keys were authored, only filter was ' + + 'ever read: deleting defaultFilters is the whole migration.', + }, { id: 'object-index-unknown-keys-refused', surface: 'object `indexes[]` entries (`IndexSchema`) — undeclared keys', @@ -12931,6 +13051,71 @@ const step18: MigrationStep = { + 'reports no `component-props-unknown-key` / `component-props-invalid` finding for the ' + 'rail.', }, + // 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 + // an upgrade guide that quietly rewrites a shipped prescription leaves the + // reader who followed it with no trace of why their metadata now fails. + { + id: 'view-filter-rule-scalar-operator-array-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' + + 'every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, ' + + 'Page.filterBy, a related-list filter, a lookup picker filter, and the filter and ' + + 'defaultFilters keys of the object-* page blocks', + replacement: + 'one scalar — a string, number, boolean or null. A rule written ' + + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' + + 'of a list becomes operator: "in" with the array unchanged. The list operators (in / ' + + 'not_in) and the range operator (between) are untouched and still take their arrays. ' + + 'The unary operators (is_empty / is_not_empty / is_null / is_not_null) are untouched ' + + 'too: they take their direction from the operator NAME and their value position is ' + + 'discarded, so whatever sits there still parses, array included. An omitted value is ' + + 'still an omitted value', + reason: + '#19514, closing the protocol half of objectui#9050 ruling C-prime (maintainer ' + + '2026-09-20, verbatim, untranslated): 「the differences are the protocol\'s to close」. ' + + 'The value key\'s own published description has declared this rule since #6227 — ' + + '「every other operator takes a scalar」 — and the refinement that implements the ' + + 'coupling returned early for every operator that is neither a list operator nor ' + + 'between, so the entire scalar class was declared and never judged. ' + + '⚠️ This REVERSES a reading recorded in the sibling entry ' + + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' + + 'which every backend answers」. Re-measured at source for this entry: the lowered node ' + + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope, and every ' + + 'in-memory matcher excludes every row for the same reason. So the earlier reading was ' + + 'the one that widened the accept set past the query path, and a stored view carrying ' + + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + + 'query path exactly and goes no further, which is the #5685 boundary this family has ' + + 'held since it was written. ' + + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + + 'two on equals has no honest single value, and picking the first is a different ' + + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' + + 'storing a filter that 400s. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + + 'then decide per rule which of the two things it meant: one value, or membership. ' + + 'os validate and os lint report each one by path with the operator, the received shape ' + + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' + + 'answered 400 INVALID_FILTER on the SQL family and excluded every row on the in-memory ' + + 'matchers — so re-check what the view is supposed to show rather than assuming the old ' + + 'result set was correct. A one-element array is the case to read closest: ' + + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + + 'today, and only the author knows which the metadata meant.', + }, { id: 'wait-node-event-config-required', surface: diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts new file mode 100644 index 00000000000..d2e95791c97 --- /dev/null +++ b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19514] `object-grid`'s `defaultFilters` carries the SAME declaration as its + * `filter` sibling. + * + * The key is described as "read only when `filter` is absent" — the same value + * in the same role — and objectui's `ObjectGrid` reads it through the same + * lowering sink. `filter` converged on the `ViewFilterRule` array with the rest + * of its family; this key was not named by that ruling and kept the + * pre-convergence `z.unknown()`, so the block had one declared door and one + * undeclared door onto one seam: a bare string, a number, a MongoDB-style + * record, an ObjectQL AST tuple array and a list of malformed rules all parsed + * here, and each of them is a refusal waiting at the lowering. + * + * These pins hold the two keys EQUAL rather than transcribing a list of shapes + * — the equality is the rule, and a list would go stale the next time `filter` + * moves. Both directions are pinned at each key: the newly-refused shapes + * REFUSE and the shape that must keep working ACCEPTS, because an ACCEPT-only + * suite passes just as well against a door that has been narrowed to refuse + * everything. + * + * ⛔ Narrowed, NOT retired. Refusing the key outright is a REMOVAL of an + * accepted shape and needs its own ruling; the deprecation already stated in + * the description is unchanged. The pin below says so in the one way a test + * can: a well-formed `defaultFilters` still parses. + */ + +import { describe, expect, it } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; + +const GRID = ComponentPropsMap['object-grid']; + +/** A valid grid node with one key under test swapped in. */ +const node = (extra: Record) => ({ objectName: 'account', ...extra }); + +/** The rule array both keys take. */ +const RULES = [{ field: 'status', operator: 'equals', value: 'active' }] as const; + +/** The shapes the `z.unknown()` door used to receipt as valid. */ +const REFUSED_SHAPES: readonly (readonly [string, unknown])[] = [ + ['a bare string', 'status = active'], + ['a number', 42], + ['a boolean', true], + ['the MongoDB-style record form', { status: 'active' }], + ['an operator-object record form', { amount: { $gt: 100 } }], + ['an ObjectQL AST tuple array', [['owner_id', '=', '{current_user_id}']]], + ['an array of malformed rules', [{ nonsense: true }]], +]; + +describe('#19514 — defaultFilters refuses what filter refuses', () => { + it.each(REFUSED_SHAPES.map(([label, value]) => [label, value] as const))( + 'refuses %s at the defaultFilters path', + (_label, value) => { + const result = GRID.safeParse(node({ defaultFilters: value })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const under = result.error.issues.filter((i) => String(i.path[0]) === 'defaultFilters'); + expect(under.length).toBeGreaterThan(0); + }, + ); + + it('the two keys agree shape for shape — the rule, not a transcribed list', () => { + // If `filter` is narrowed or widened again, this is what holds the fallback + // to it. A list of literals here would silently stop tracking. + for (const [label, value] of REFUSED_SHAPES) { + const onFilter = GRID.safeParse(node({ filter: value })).success; + const onFallback = GRID.safeParse(node({ defaultFilters: value })).success; + expect(onFallback, `${label}: defaultFilters`).toBe(onFilter); + } + expect(GRID.safeParse(node({ filter: RULES })).success).toBe(true); + expect(GRID.safeParse(node({ defaultFilters: RULES })).success).toBe(true); + }); + + it('the record form gets the conversion table, naming the key that was written', () => { + const result = GRID.safeParse(node({ defaultFilters: { status: 'active' } })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const issue = result.error.issues.find((i) => i.path.join('.') === 'defaultFilters')!; + expect(issue.message).toContain('`defaultFilters`'); + expect(issue.message).toContain('[{ field, operator, value }, ...]'); + // The rewrite is computed from the author's own keys, as at every sibling door. + expect(issue.message).toContain("[{ field: 'status', operator: 'equals', value: 'active' }]"); + expect(issue.message).toContain('migration `object-grid-default-filters-rule-array`'); + }); + + it.each([ + ['the rule array', RULES], + ['an empty rule array', []], + ['a multi-rule array', [ + { field: 'status', operator: 'equals', value: 'active' }, + { field: 'stage', operator: 'in', value: ['won', 'lost'] }, + ]], + ])('NEGATIVE CONTROL — still accepts %s', (_label, value) => { + expect(GRID.safeParse(node({ defaultFilters: value })).success).toBe(true); + }); + + it('NEGATIVE CONTROL — absence is still absence, and the key is still optional', () => { + const result = GRID.safeParse(node({})); + expect(result.success).toBe(true); + if (!result.success) throw new Error('unreachable'); + expect('defaultFilters' in (result.data as Record)).toBe(false); + }); + + it('NEGATIVE CONTROL — both keys together still parse, as the fallback contract allows', () => { + // The key is read only when `filter` is absent; authoring both has never + // been an error and this narrowing does not make it one. + expect(GRID.safeParse(node({ filter: RULES, defaultFilters: RULES })).success).toBe(true); + }); + + it('ENVELOPE CONTROL — the door refuses an unrelated thing, so it is reachable', () => { + // The card's own discriminating control. If this reads ACCEPT, the block is + // not being parsed at all and every REFUSE above is a phantom. + const result = GRID.safeParse({ objectName: 42 }); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + expect(result.error.issues.some((i) => i.path.join('.') === 'objectName')).toBe(true); + }); + + it('the element-level issues of an array author are still their own', () => { + // The fall-through `ruleArrayFilterError` protects: a blanket message at the + // key would overwrite the diagnosis an array author actually needs. + const result = GRID.safeParse(node({ defaultFilters: [{ field: 'status', operator: 'nope', value: 'a' }] })); + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable'); + const under = result.error.issues.filter((i) => i.path.join('.').startsWith('defaultFilters.')); + expect(under.length).toBeGreaterThan(0); + for (const issue of under) expect(issue.message).not.toContain('migration `'); + expect(result.error.issues.filter((i) => i.path.join('.') === 'defaultFilters')).toHaveLength(0); + }); + + it('the rule-level narrowings reach THIS key too — one schema, every carrier', () => { + // The scalar arm and the icontains comparand door are `ViewFilterRuleSchema`'s, + // so they arrive here by construction. Pinned because "the fallback is the + // same declaration" is the whole claim of this file. + expect(GRID.safeParse(node({ + defaultFilters: [{ field: 'tags', operator: 'equals', value: ['a'] }], + })).success).toBe(false); + expect(GRID.safeParse(node({ + defaultFilters: [{ field: 'name', operator: 'icontains', value: '' }], + })).success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index c8dd7a4b5b8..f0e543bbe81 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -7,11 +7,23 @@ * `{ field: 'stage', operator: 'not_in', value: 'won' }` published cleanly and * then answered a named 400 `INVALID_FILTER` at query time (#5869 / PR #6209 * closed that runtime half). The author was gone by then. These pins assert the - * publish-time half now refuses the same three shapes the runtime refuses — + * publish-time half now refuses the same shapes the runtime refuses — * `$in`/`$nin` must be arrays, `$between` must be a 2-array — and, just as * importantly, that it refuses NOTHING ELSE (#5685: a schema stricter than the * runtime is the wrong side). * + * [#19514] The SCALAR arm joined them, and it moved three pins in this file from + * the accepted side to the refused side. It is not an extension of #6227's + * reasoning but a correction of one of its readings: an array on a scalar + * operator was recorded here as accepted because it 「lowers to a bare + * deep-equality comparand, which every backend answers」, and re-measurement + * found the opposite — `driver-sql` refuses the comparand with `INVALID_FILTER` + * and the in-memory matchers exclude every row, so a view that PASSED the + * protocol selected nothing. The pins below carry both directions of that arm, + * and the carve-outs (an absent value, the four valueless operators) keep their + * own pins, because the #5685 side of this file is what stops a narrowing from + * running on past the query path. + * * Every rejection pin asserts the issue PATH and the message's leading sentence, * not merely that a throw happened: a bare `.toThrow()` cannot tell "refused for * the right reason at the right key" from "refused because the value union @@ -29,6 +41,34 @@ import { /** Parse helper — the authored object form, exactly as a view carries it. */ const parse = (rule: Record) => ViewFilterRuleSchema.safeParse(rule); +/** + * The operators whose value position is discarded downstream — they take their + * direction from the operator NAME. Transcribed here rather than imported + * because the schema keeps its copy PRIVATE on purpose (publishing it would + * enlarge the package's public face for a question only the refinement asks), + * and the sweep below is what holds the two lists equal: an operator dropped + * from the schema's set reddens the array half of the sweep, and one added to + * it reddens the carve-out pin. + */ +const VALUELESS_OPERATORS = ['is_empty', 'is_not_empty', 'is_null', 'is_not_null'] as const; + +/** + * Every operator this check judges as taking a SCALAR — the canonical + * vocabulary minus the list set, the range set and the four above. DERIVED, so + * an operator added to the enum arrives in both directions of the sweep instead + * of quietly skipping it. + */ +const UNSHAPED_VALUE_OPERATORS: readonly string[] = VIEW_FILTER_OPERATORS.filter( + (operator) => + !( + [ + ...VIEW_FILTER_LIST_VALUE_OPERATORS, + ...VIEW_FILTER_PAIR_VALUE_OPERATORS, + ...VALUELESS_OPERATORS, + ] as readonly string[] + ).includes(operator), +); + /** The single `value`-path issue a shape refusal must produce. */ function valueIssue(result: ReturnType) { expect(result.success).toBe(false); @@ -135,18 +175,24 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the ['not_in + empty array', { field: 'f', operator: 'not_in', value: [] }], ['between + pair', { field: 'f', operator: 'between', value: [1, 2] }], ['between + ISO date pair', { field: 'd', operator: 'between', value: ['2024-01-01', '2024-12-31'] }], - // A scalar operator carrying an array lowers to a deep-equality comparand. - ['equals + array', { field: 'f', operator: 'equals', value: ['a', 'b'] }], - ['not_equals + array', { field: 'f', operator: 'not_equals', value: ['a'] }], - // A string operator carrying a number: no backend refuses it. + // A string operator carrying a number: no backend refuses it. (`icontains` + // is the one exception and it is the TABLE's row, not an analogy — see the + // comparand pins below.) ['contains + number', { field: 'f', operator: 'contains', value: 5 }], ['starts_with + number', { field: 'f', operator: 'starts_with', value: 5 }], // Ordering operators take a scalar of any declared type (#5685 widened these). ['greater_than + ISO string', { field: 'd', operator: 'greater_than', value: '2026-01-01' }], ['before + string', { field: 'd', operator: 'before', value: '2026-01-01' }], ['after + string', { field: 'd', operator: 'after', value: '2026-01-01' }], - // Ordering operators carrying an array are not this check's business either. - ['greater_than + array', { field: 'f', operator: 'greater_than', value: [1, 2] }], + // A scalar operator with NO value: `value` is optional and absence is not a + // shape. The scalar arm must not turn an omitted comparand into a refusal. + ['equals + omitted', { field: 'f', operator: 'equals' }], + ['greater_than + omitted', { field: 'd', operator: 'greater_than' }], + // Every scalar type the declared union carries still parses on a scalar operator. + ['equals + null', { field: 'f', operator: 'equals', value: null }], + ['equals + boolean', { field: 'f', operator: 'equals', value: false }], + ['equals + empty string', { field: 'f', operator: 'equals', value: '' }], + ['equals + zero', { field: 'f', operator: 'equals', value: 0 }], // Alias spellings with a CONFORMING value keep parsing. ['nin alias + array', { field: 'f', operator: 'nin', value: ['a'] }], ['notIn alias + array', { field: 'f', operator: 'notIn', value: ['a'] }], @@ -169,16 +215,80 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the }, ); - it('leaves every operator outside the two shaped vocabularies unjudged', () => { - const shaped = new Set([ - ...VIEW_FILTER_LIST_VALUE_OPERATORS, - ...VIEW_FILTER_PAIR_VALUE_OPERATORS, - ]); - for (const operator of VIEW_FILTER_OPERATORS) { - if (shaped.has(operator)) continue; - // Both a scalar and an array parse for every unshaped operator. - expect(parse({ field: 'f', operator, value: 'x' }).success).toBe(true); - expect(parse({ field: 'f', operator, value: ['x'] }).success).toBe(true); + it('takes a SCALAR on every operator outside the list, range and valueless sets', () => { + // [#19514] The sweep that used to read "both a scalar and an array parse" + // for these operators. The scalar half is unchanged and is the half this + // assertion protects: a narrowing that made the common shape refuse would + // fail here before it reached a consumer. The array half moved and has its + // own two-directional sweep below. + for (const operator of UNSHAPED_VALUE_OPERATORS) { + expect(parse({ field: 'f', operator, value: 'x' }).success, operator).toBe(true); + } + }); +}); + +describe('#19514 — the scalar arm, in both directions', () => { + /** The three pins this arm MOVED, named as such so the reversal is legible. */ + it.each([ + ['equals + array', { field: 'f', operator: 'equals', value: ['a', 'b'] }, 'equals'], + ['not_equals + array', { field: 'f', operator: 'not_equals', value: ['a'] }, 'not_equals'], + ['greater_than + array', { field: 'f', operator: 'greater_than', value: [1, 2] }, 'greater_than'], + ])('refuses %s — recorded as ACCEPTED at #6227, reversed on measurement', (_label, rule, operator) => { + const issue = valueIssue(parse(rule as Record)); + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['value']); + expect(issue.message).toContain(`Operator "${operator}" on field "f" requires a SCALAR value.`); + // Not the list wording and not the range wording — three arms, three + // diagnoses, and an author must be able to tell which one fired. + expect(issue.message).not.toContain('requires an ARRAY of values'); + expect(issue.message).not.toContain('requires a [min, max] value array'); + // The refusal carries what to DO, and the query-path code to match it against. + expect(issue.message).toContain('to compare against one value'); + expect(issue.message).toContain('or use "in" to test membership of the list'); + expect(issue.message).toContain('400 INVALID_FILTER'); + }); + + it('prescribes the author OWN first member, not a canned example', () => { + const issue = valueIssue(parse({ field: 'stage', operator: 'equals', value: ['won', 'lost'] })); + expect(issue.message).toContain('Received an array of 2 (["won","lost"])'); + expect(issue.message).toContain('write "won" to compare against one value'); + }); + + it('has something to say about an EMPTY array too, where there is no first member', () => { + const issue = valueIssue(parse({ field: 'stage', operator: 'equals', value: [] })); + expect(issue.message).toContain('Received an array of 0 ([])'); + expect(issue.message).toContain('write the value to compare against'); + }); + + it('names the two vocabularies from the exported sets, not from a transcription', () => { + const issue = valueIssue(parse({ field: 'f', operator: 'equals', value: ['a'] })); + for (const operator of VIEW_FILTER_LIST_VALUE_OPERATORS) expect(issue.message).toContain(`"${operator}"`); + for (const operator of VIEW_FILTER_PAIR_VALUE_OPERATORS) expect(issue.message).toContain(`"${operator}"`); + }); + + it('carries no internal tracker id — the reader of this string cannot open one', () => { + const issue = valueIssue(parse({ field: 'f', operator: 'equals', value: ['a'] })); + expect(issue.message).not.toMatch(/(? { + // The two-directional sweep. A one-directional one would stay green if the + // arm refused everything, which is the failure an inert door looks like. + expect(UNSHAPED_VALUE_OPERATORS.length).toBeGreaterThan(0); + for (const operator of UNSHAPED_VALUE_OPERATORS) { + expect(parse({ field: 'f', operator, value: ['x'] }).success, `${operator} + array`).toBe(false); + expect(parse({ field: 'f', operator, value: 'x' }).success, `${operator} + scalar`).toBe(true); + } + }); + + it('leaves the four VALUELESS operators alone, array included', () => { + // The carve-out that keeps this narrowing from running past the query path: + // `convertComparison` maps these to `{ $null: true|false }` and discards the + // value position, and the ObjectUI client sends a truthy PLACEHOLDER there. + for (const operator of VALUELESS_OPERATORS) { + expect(parse({ field: 'f', operator, value: ['x'] }).success, operator).toBe(true); + expect(parse({ field: 'f', operator, value: 'x' }).success, operator).toBe(true); + expect(parse({ field: 'f', operator }).success, operator).toBe(true); } }); }); From 26595ea42166b9fa5b81ec36d7a8b1d63a5bf315 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:18:55 +0000 Subject: [PATCH 03/17] test(spec): re-judge the two fixtures the narrowings moved (#19514) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../data/filter-icontains-parse-door.test.ts | 6 +++++- packages/spec/src/ui/component.test.ts | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/data/filter-icontains-parse-door.test.ts b/packages/spec/src/data/filter-icontains-parse-door.test.ts index a25397aeeb1..82b1e04d950 100644 --- a/packages/spec/src/data/filter-icontains-parse-door.test.ts +++ b/packages/spec/src/data/filter-icontains-parse-door.test.ts @@ -120,7 +120,11 @@ describe('#19514 §1 — the $ dialect refuses what the table declares refused', 'created.$gt', ); expect(issue.message).toContain('PRESET'); - expect(issue.message).not.toContain('INVALID_FILTER'); + // ⚠️ NOT asserted by the absence of `INVALID_FILTER`: the preset refusal + // names that code too, in its own prose about the engine. What separates + // the two refusals is the comparand door's own wording. + expect(issue.message).not.toContain('EMPTY STRING'); + expect(issue.message).not.toContain('not a string'); }); it.each([ diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 5279794ce70..431b17bb839 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3186,11 +3186,30 @@ describe('#7751 — object-* block props schemas', () => { // ObjectGrid.tsx reads it and lowers it to `$filter` when `filter` is // absent (the routed finding on #7751 verified the read point). Only the // plural `filters` has zero read points. + // + // [#19514] The VALUE this pin carries moved, and the pin's subject did not. + // The key is still honoured and still parses; what changed is that it now + // carries `filter`'s own declaration — the same value in the same role, + // read through the same lowering sink — instead of `z.unknown()`. The AST + // tuple array this pin used to spell was one of the five shapes that sink + // refuses, so the old fixture was pinning a receipt for a filter that never + // ran. Its refusal is pinned below, and in full at + // `component-object-grid-default-filters.pin.test.ts`. + const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; const parsed = ComponentPropsMap['object-grid'].parse({ + objectName: 'showcase_task', + defaultFilters: rules, + }); + expect(parsed.defaultFilters).toEqual(rules); + }); + + it('`defaultFilters` refuses the AST tuple array the `z.unknown()` door used to receipt (#19514)', () => { + const r = ComponentPropsMap['object-grid'].safeParse({ objectName: 'showcase_task', defaultFilters: [['status', '=', 'open']], }); - expect(parsed.defaultFilters).toEqual([['status', '=', 'open']]); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => String(i.path[0]) === 'defaultFilters')).toBe(true); }); // #11805 — the grid's legacy single-sort fallback, retired by maintainer From 1a2a7eb588031bbe66ef022767c394e8c66e71d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:21:18 +0000 Subject: [PATCH 04/17] chore(changeset): declare the three narrowings, their level and their ADR-0087 disposition (#19514) Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...rule-scalar-arm-and-icontains-comparand.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md new file mode 100644 index 00000000000..5e6afded05e --- /dev/null +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -0,0 +1,53 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: the filter doors refuse the three shapes they already declared refused — a scalar operator's array, an `icontains` comparand the conformance table rejects, and an ungated `defaultFilters` (#19514) + +**BREAKING** — three accept-set narrowings on published authoring surfaces, each pulling the door back to what this package already declared somewhere an author's parse never reached. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying any of these shapes keeps loading and keeps rendering exactly as it does today; what changes is that RE-SAVING it is refused, at the key that carries the mistake. The hand-migration prescriptions are registered under protocol major 18 as `view-filter-rule-scalar-operator-array-refused`, `filter-icontains-comparand-refused-at-parse` and `object-grid-default-filters-rule-array`. + +Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differences are the protocol's to close」. + +## 1. A scalar operator carrying an ARRAY is refused + +`ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so the entire scalar class was declared and never judged. + +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured at source: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which asserts the comparand against its own scalar-operator set; an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), so the comparand is refused with the withheld `INVALID_FILTER` / 400 envelope, and the in-memory matchers exclude every row for the same reason. **A stored view that passed the protocol selected nothing** — and unlike a 400, the in-memory answer reads as a true statement about the data. + +Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. + +## 2. The `icontains` comparands the platform's own table declares refused + +`@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. Every backend answers those rows. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. + +The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. Scope is the one operator the table writes rows for: `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. + +One asymmetry between the two vocabularies, and it is a fact about them rather than an extra rule: a view rule's `value` is optional, so an **absent** comparand is left unjudged there; the `$` dialect has no absent, so an explicit `undefined` in a comparand slot is the refused non-string shape. + +## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration + +The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam. A record-form fallback got a silent success receipt and a 400 at render, with nothing in between to say which of the two keys was the problem. + +⛔ **Narrowed, not retired.** Refusing the key outright is a removal of an accepted shape and needs its own ruling. The deprecation already stated in the description is unchanged: prefer `filter`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ field: 'tags', operator: 'equals', value: ['a'] }` | `{ field: 'tags', operator: 'equals', value: 'a' }` — or `operator: 'in'` if membership was meant | +| `{ field: 'name', operator: 'icontains', value: '' }` | delete the condition — every value contains the empty substring | +| `{ field: 'name', operator: 'icontains', value: 42 }` | `value: '42'`, if a substring match on those two characters was really meant | +| `{ name: { $icontains: '' } }` | delete the condition | +| `{ name: { $icontains: 42 } }` | `{ name: { $icontains: '42' } }` | +| `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | +| `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | + +Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. Worth doing even where it looks unnecessary: **none of these shapes has ever returned filtered rows**, so re-check what each view is supposed to show rather than assuming the old result set was correct. The one to read closest is a one-element array — `value: ['won']` on `equals` and `operator: 'in'` with `value: ['won']` select the same rows, and only the author knows which the metadata meant. + +## Who is affected, measured + +Nothing in this repository authored any of the three shapes. Two fixtures pinned the old accept set and were re-judged rather than rewritten by rote: one asserted that a scalar-operator array parses (it pinned the reading paragraph 1 reverses), and one parsed an ObjectQL AST tuple array on `defaultFilters` to prove the key is HONOURED — that subject survives, on the rule array, with the tuple array's refusal pinned beside it. The full `@objectstack/spec` suite is green, and `check:api-surface` reports no export moved: no symbol is added, removed or renamed by this change. + +Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves, and no new published vocabulary is introduced (the two operator sets the refusals name are the ones already exported, and the two the checks needed for themselves are deliberately module-private). Every one of the three accept sets narrows back to what this package had already declared: a published `.describe()` for the first, a published conformance table for the second, and the sibling key's own declaration for the third. + + From 536bc37fe0517949b8213e59080ae422cdab7630 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 22:39:16 +0000 Subject: [PATCH 05/17] docs(spec): name each backend in the reversal prose instead of one uniform claim (#19514) The reversal paragraph said every in-memory matcher "excludes every row". Re-measured by running all three backends at this head: driver-sql find() on the lowered { tags: ['a'] } -> THREW INVALID_FILTER / 400 (sqlite cell; the gate is assertCompilableComparand, upstream of dialect emission). Control { tags: 'a' } returned the row. driver-memory match({tags:['a']}, {tags:['a']}) -> THREW INVALID_FILTER / 400 (assertFilterConditionShape's implicit-equality arm). Control scalar comparand answered true. formula matchesFilterCondition(row, {tags:['a']}) -> false for every row, including a row whose stored value IS ['a']. Control answered true. So two backends REFUSE and one EXCLUDES. The conclusion is unchanged and stronger -- no backend answers the shape, and the stored view never returned filtered rows -- but the mechanism is now stated per backend at every site that ships: the view.zod.ts docblock, the changeset's section 1, and the view-filter-rule-scalar-operator-array-refused entry's reason and acceptanceCriteria (which project into CHANGELOG.md and the upgrade guide). The same sentence in view-filter-rule-value-shape.test.ts's docblock is corrected with it. registry.ts is the regenerated mirror of the entry. Prose only: no accept set, pin, test body or changeset level moves. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...-rule-scalar-arm-and-icontains-comparand.md | 2 +- ...ilter-rule-scalar-operator-array-refused.ts | 12 ++++++++---- packages/spec/src/migrations/registry.ts | 12 ++++++++---- .../ui/view-filter-rule-value-shape.test.ts | 9 +++++---- packages/spec/src/ui/view.zod.ts | 18 +++++++++++++----- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index 5e6afded05e..b48221b0b3b 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -12,7 +12,7 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ `ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so the entire scalar class was declared and never judged. -⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured at source: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which asserts the comparand against its own scalar-operator set; an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), so the comparand is refused with the withheld `INVALID_FILTER` / 400 envelope, and the in-memory matchers exclude every row for the same reason. **A stored view that passed the protocol selected nothing** — and unlike a 400, the in-memory answer reads as a true statement about the data. +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured by RUNNING all three backends: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which asserts the comparand against its own scalar-operator set; an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), so the comparand is refused with the withheld `INVALID_FILTER` / 400 envelope. The in-memory half is not uniform, and each backend is named rather than generalised: `driver-memory`'s matcher refuses the same shape in the same envelope, and `@objectstack/formula`'s matcher — the one backend that answers the shape at all — excludes every row. **A stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 797e4bed11c..07ddd13b63f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -41,8 +41,11 @@ export const entry: SemanticMigration = { + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope, and every ' - + 'in-memory matcher excludes every row for the same reason. So the earlier reading was ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope. The in-memory ' + + 'half is not uniform, and each backend is named rather than generalised: driver-memory ' + + 'REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on ' + + 'an array in the implicit-equality position), while the formula matcher — the one ' + + 'backend that answers the shape at all — excludes every row. So the earlier reading was ' + 'the one that widened the accept set past the query path, and a stored view carrying ' + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + 'query path exactly and goes no further, which is the #5685 boundary this family has ' @@ -61,8 +64,9 @@ export const entry: SemanticMigration = { + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' - + 'answered 400 INVALID_FILTER on the SQL family and excluded every row on the in-memory ' - + 'matchers — so re-check what the view is supposed to show rather than assuming the old ' + + 'answered 400 INVALID_FILTER on the SQL family and on driver-memory, and the formula ' + + 'matcher, the one backend that answers the shape at all, excluded every row — so ' + + 're-check what the view is supposed to show rather than assuming the old ' + 'result set was correct. A one-element array is the case to read closest: ' + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + 'today, and only the author knows which the metadata meant.', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8e897c8e970..47066bae34e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13090,8 +13090,11 @@ const step18: MigrationStep = { + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope, and every ' - + 'in-memory matcher excludes every row for the same reason. So the earlier reading was ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope. The in-memory ' + + 'half is not uniform, and each backend is named rather than generalised: driver-memory ' + + 'REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on ' + + 'an array in the implicit-equality position), while the formula matcher — the one ' + + 'backend that answers the shape at all — excludes every row. So the earlier reading was ' + 'the one that widened the accept set past the query path, and a stored view carrying ' + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + 'query path exactly and goes no further, which is the #5685 boundary this family has ' @@ -13110,8 +13113,9 @@ const step18: MigrationStep = { + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' - + 'answered 400 INVALID_FILTER on the SQL family and excluded every row on the in-memory ' - + 'matchers — so re-check what the view is supposed to show rather than assuming the old ' + + 'answered 400 INVALID_FILTER on the SQL family and on driver-memory, and the formula ' + + 'matcher, the one backend that answers the shape at all, excluded every row — so ' + + 're-check what the view is supposed to show rather than assuming the old ' + 'result set was correct. A one-element array is the case to read closest: ' + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + 'today, and only the author knows which the metadata meant.', diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index f0e543bbe81..a0c9e5ad036 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -17,10 +17,11 @@ * reasoning but a correction of one of its readings: an array on a scalar * operator was recorded here as accepted because it 「lowers to a bare * deep-equality comparand, which every backend answers」, and re-measurement - * found the opposite — `driver-sql` refuses the comparand with `INVALID_FILTER` - * and the in-memory matchers exclude every row, so a view that PASSED the - * protocol selected nothing. The pins below carry both directions of that arm, - * and the carve-outs (an absent value, the four valueless operators) keep their + * found the opposite — `driver-sql` AND `driver-memory` both refuse the + * comparand with `INVALID_FILTER`, and `@objectstack/formula`'s matcher, the one + * backend that answers the shape at all, excludes every row, so a view that + * PASSED the protocol selected nothing. The pins below carry both directions of + * that arm, and the carve-outs (an absent value, the four valueless operators) keep their * own pins, because the #5685 side of this file is what stops a narrowing from * running on past the query path. * diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index a20636191da..9fbce69447f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -585,14 +585,22 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality - * comparand, which every backend answers". Re-measured at source: the lowered - * `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which - * calls `assertCompilableComparand(column, '=', value)`; `'='` is in that file's + * comparand, which every backend answers". Re-measured by RUNNING all three + * backends: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare + * `{ field: value }` loop, which calls + * `assertCompilableComparand(column, '=', value)`; `'='` is in that file's * `SCALAR_COMPARAND_OPERATORS`, `isBindableComparand(['a'])` is `false` (an array * is none of the six accepted comparand types — `isAcceptedFilterComparand`, * `filter-comparand-type.ts`), and the comparand is refused with the withheld - * `INVALID_FILTER` / 400 envelope. Every in-memory matcher excludes every row for - * the same reason. So the ORIGINAL reading was the one that widened the accept + * `INVALID_FILTER` / 400 envelope. **The in-memory half is not uniform, and the + * difference is stated per backend rather than generalised:** `driver-memory` + * REFUSES the same shape in the same envelope — `match()` runs + * `assertFilterConditionShape`, whose implicit-equality arm throws on an array + * (`filter-refusal.ts`) — while `@objectstack/formula`'s + * `matchesFilterCondition` is the one backend that answers the shape at all, and + * it answers `false` for every row, a row whose stored value IS `['a']` + * included. Two refusals and one exclusion: no backend selects the row the + * author meant. So the ORIGINAL reading was the one that widened the accept * set past the query path; this arm pulls it back to what `value`'s own * `.describe()` has declared all along — 「every other operator takes a scalar」. * Direction set by objectui#9050's ruling C′ (「the differences are the From 0e0387aab965c4eb9f4e226f46e13a55601ecff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 00:01:31 +0000 Subject: [PATCH 06/17] docs(spec): count the backend population as four and name MongoDB's answer (#19514) The previous round fixed the mechanism sentence and, in the same sentences, introduced a false statement about the POPULATION: five shipping sites called @objectstack/formula "the one backend that answers the shape at all", the view.zod.ts docblock added "Two refusals and one exclusion: no backend selects the row the author meant", and acceptanceCriteria -- which projects into CHANGELOG.md and the protocol-18 upgrade guide -- said such a rule "has never returned filtered rows". There are FOUR shipped backends, and the fourth answers. Re-measured here, one backend at a time, on the lowered { tags: ['a'] }: driver-sql assertCompilableComparand(column, '=', value) at the bare { field: value } loop -> INVALID_FILTER / 400, and with it driver-turso, driver-sqlite-wasm and turso's remote transport. driver-memory match({tags:['a']}, {tags:['a']}) -> THREW INVALID_FILTER / 400 ("The implicit-equality comparand on field \"tags\" requires a single comparable value, but received an array"). Control scalar comparand answered true. formula matchesFilterCondition -> false for all four rows probed, including a row stored as exactly ['a']. Control answered true. driver-mongodb translateFilter({tags:['a']}) -> EMITS {"tags":["a"]} unchanged (control {tags:'a'} -> {"tags":"a"}); the engine's shared doors normalizeFilterComparandTypes and assertListComparandShapes BOTH pass the shape; and MongoDB's query semantics make that document an exact-array equality -- measured through mingo, which matches a row stored as exactly ['a'] (true) and neither 'a' nor ['a','b'] (false). A live mongod cell is NOT MEASURED: the egress proxy blocks the binary download. What is measured is the driver's compile face, the engine's shared doors, and the query semantics. So the corrected claim is narrower and true: no backend reads the array as the SCALAR the operator declares; three refuse or exclude it, and only MongoDB returns rows at all, and only for an array-valued field. The exclusivity wording is deleted at all five sites, plus a sixth in the changeset's FROM->TO section that generalised "none of these shapes has ever returned filtered rows". Prose only: the transpiled emit of view.zod.ts and view-filter-rule-value-shape.test.ts is byte-identical to the previous head, the entry's id / surface / replacement are unchanged, and no accept set, pin or changeset level moves. registry.ts is the regenerated mirror of the entry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...rule-scalar-arm-and-icontains-comparand.md | 15 +++++- ...lter-rule-scalar-operator-array-refused.ts | 39 ++++++++++----- packages/spec/src/migrations/registry.ts | 39 ++++++++++----- .../ui/view-filter-rule-value-shape.test.ts | 16 +++++-- packages/spec/src/ui/view.zod.ts | 47 ++++++++++++------- 5 files changed, 108 insertions(+), 48 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index b48221b0b3b..fe643c3b2d5 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -12,7 +12,18 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ `ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so the entire scalar class was declared and never judged. -⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured by RUNNING all three backends: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` loop, which asserts the comparand against its own scalar-operator set; an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), so the comparand is refused with the withheld `INVALID_FILTER` / 400 envelope. The in-memory half is not uniform, and each backend is named rather than generalised: `driver-memory`'s matcher refuses the same shape in the same envelope, and `@objectstack/formula`'s matcher — the one backend that answers the shape at all — excludes every row. **A stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured by RUNNING the shipped backends one at a time — there are **four**, and they do not agree: + +| backend | what it does with the lowered `{ tags: ['a'] }` | +|:--|:--| +| the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | +| `driver-memory` | **REFUSES** — the same shape in the same envelope | +| `@objectstack/formula` | **EXCLUDES** — `matchesFilterCondition` answers `false` for every row, a row whose stored value IS `['a']` included | +| `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server runs an **exact-array equality**: it selects a row stored as exactly `['a']` and nothing else | + +⚠️ The MongoDB reading is taken at the driver's compile face, at the engine's shared comparand doors and against MongoDB's query semantics; a live `mongod` cell is NOT MEASURED. + +**No backend reads the array as the scalar the operator declares.** Three refuse or exclude it outright, so **a stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. The fourth returns rows, but for a different predicate and only on an array-valued field, which reads as a true statement about the data too. Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. @@ -42,7 +53,7 @@ The key is described as *"Legacy base-filter fallback, read only when `filter` i | `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | | `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | -Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. Worth doing even where it looks unnecessary: **none of these shapes has ever returned filtered rows**, so re-check what each view is supposed to show rather than assuming the old result set was correct. The one to read closest is a one-element array — `value: ['won']` on `equals` and `operator: 'in'` with `value: ['won']` select the same rows, and only the author knows which the metadata meant. +Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. Worth doing even where it looks unnecessary: **none of these shapes has ever returned the rows it declares** — the scalar-operator array returned nothing at all on every backend but MongoDB, and on MongoDB only the rows whose stored value is that exact array — so re-check what each view is supposed to show rather than assuming the old result set was correct. The one to read closest is a one-element array — `value: ['won']` on `equals` and `operator: 'in'` with `value: ['won']` select the same rows, and only the author knows which the metadata meant. ## Who is affected, measured diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 07ddd13b63f..5bf11e4fef6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -37,17 +37,28 @@ export const entry: SemanticMigration = { + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. Re-measured at source for this entry: the lowered node ' + + 'which every backend answers」. Re-measured for this entry, one backend at a time: the ' + + 'lowered node ' + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope. The in-memory ' - + 'half is not uniform, and each backend is named rather than generalised: driver-memory ' - + 'REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on ' - + 'an array in the implicit-equality position), while the formula matcher — the one ' - + 'backend that answers the shape at all — excludes every row. So the earlier reading was ' - + 'the one that widened the accept set past the query path, and a stored view carrying ' - + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the ' + + 'driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso\'s remote ' + + 'transport. The remaining backends are named one by one rather than generalised, ' + + 'because the FOUR that ship do not agree: driver-memory REFUSES the same shape in the ' + + 'same envelope (its assertFilterConditionShape throws on an array in the ' + + 'implicit-equality position); the formula matcher EXCLUDES every row, a row whose ' + + 'stored value IS the array included; and driver-mongodb ANSWERS — its translateFilter ' + + 'passes the array through unchanged and the engine\'s shared comparand doors ' + + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + + 'server runs an exact-array equality that selects a row stored as exactly that array ' + + 'and nothing else. That MongoDB reading is taken at the driver\'s compile face, at those ' + + 'engine doors and against MongoDB\'s query semantics; a live mongod instance was NOT ' + + 'measured. No backend reads the array as the SCALAR the operator declares. So the ' + + 'earlier reading was the one that widened the accept set past the query path, and a ' + + 'stored view carrying this shape PASSED the protocol and then selected nothing on ' + + 'every backend but MongoDB, where it selected by a predicate the rule never wrote. ' + + 'The narrowing mirrors the ' + 'query path exactly and goes no further, which is the #5685 boundary this family has ' + 'held since it was written. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' @@ -63,11 +74,13 @@ export const entry: SemanticMigration = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' - + 'answered 400 INVALID_FILTER on the SQL family and on driver-memory, and the formula ' - + 'matcher, the one backend that answers the shape at all, excluded every row — so ' - + 're-check what the view is supposed to show rather than assuming the old ' - + 'result set was correct. A one-element array is the case to read closest: ' + + 'Worth knowing before you rewrite: on every backend but MongoDB such a rule has never ' + + 'returned filtered rows — it answered 400 INVALID_FILTER on the SQL family and on ' + + 'driver-memory, and the formula matcher excluded every row. On driver-mongodb it DID ' + + 'return rows, but not the ones the operator declares: the array is passed through to ' + + 'the server as an exact-array equality, so it selected only rows whose stored value is ' + + 'that exact array. Either way, re-check what the view is supposed to show rather than ' + + 'assuming the old result set was correct. A one-element array is the case to read closest: ' + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + 'today, and only the author knows which the metadata meant.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 47066bae34e..b9363c86386 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13086,17 +13086,28 @@ const step18: MigrationStep = { + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. Re-measured at source for this entry: the lowered node ' + + 'which every backend answers」. Re-measured for this entry, one backend at a time: the ' + + 'lowered node ' + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope. The in-memory ' - + 'half is not uniform, and each backend is named rather than generalised: driver-memory ' - + 'REFUSES the same shape in the same envelope (its assertFilterConditionShape throws on ' - + 'an array in the implicit-equality position), while the formula matcher — the one ' - + 'backend that answers the shape at all — excludes every row. So the earlier reading was ' - + 'the one that widened the accept set past the query path, and a stored view carrying ' - + 'this shape PASSED the protocol and then selected nothing. The narrowing mirrors the ' + + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the ' + + 'driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso\'s remote ' + + 'transport. The remaining backends are named one by one rather than generalised, ' + + 'because the FOUR that ship do not agree: driver-memory REFUSES the same shape in the ' + + 'same envelope (its assertFilterConditionShape throws on an array in the ' + + 'implicit-equality position); the formula matcher EXCLUDES every row, a row whose ' + + 'stored value IS the array included; and driver-mongodb ANSWERS — its translateFilter ' + + 'passes the array through unchanged and the engine\'s shared comparand doors ' + + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + + 'server runs an exact-array equality that selects a row stored as exactly that array ' + + 'and nothing else. That MongoDB reading is taken at the driver\'s compile face, at those ' + + 'engine doors and against MongoDB\'s query semantics; a live mongod instance was NOT ' + + 'measured. No backend reads the array as the SCALAR the operator declares. So the ' + + 'earlier reading was the one that widened the accept set past the query path, and a ' + + 'stored view carrying this shape PASSED the protocol and then selected nothing on ' + + 'every backend but MongoDB, where it selected by a predicate the rule never wrote. ' + + 'The narrowing mirrors the ' + 'query path exactly and goes no further, which is the #5685 boundary this family has ' + 'held since it was written. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' @@ -13112,11 +13123,13 @@ const step18: MigrationStep = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite: such a rule has never returned filtered rows — it ' - + 'answered 400 INVALID_FILTER on the SQL family and on driver-memory, and the formula ' - + 'matcher, the one backend that answers the shape at all, excluded every row — so ' - + 're-check what the view is supposed to show rather than assuming the old ' - + 'result set was correct. A one-element array is the case to read closest: ' + + 'Worth knowing before you rewrite: on every backend but MongoDB such a rule has never ' + + 'returned filtered rows — it answered 400 INVALID_FILTER on the SQL family and on ' + + 'driver-memory, and the formula matcher excluded every row. On driver-mongodb it DID ' + + 'return rows, but not the ones the operator declares: the array is passed through to ' + + 'the server as an exact-array equality, so it selected only rows whose stored value is ' + + 'that exact array. Either way, re-check what the view is supposed to show rather than ' + + 'assuming the old result set was correct. A one-element array is the case to read closest: ' + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' + 'today, and only the author knows which the metadata meant.', }, diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index a0c9e5ad036..9d97f06f800 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -17,10 +17,18 @@ * reasoning but a correction of one of its readings: an array on a scalar * operator was recorded here as accepted because it 「lowers to a bare * deep-equality comparand, which every backend answers」, and re-measurement - * found the opposite — `driver-sql` AND `driver-memory` both refuse the - * comparand with `INVALID_FILTER`, and `@objectstack/formula`'s matcher, the one - * backend that answers the shape at all, excludes every row, so a view that - * PASSED the protocol selected nothing. The pins below carry both directions of + * found the opposite, and the four shipped backends do not agree: the SQL + * family (`driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built + * on it, and turso's remote transport) and `driver-memory` REFUSE the comparand + * with `INVALID_FILTER`; `@objectstack/formula`'s matcher EXCLUDES every row; + * and `driver-mongodb` ANSWERS — it passes the array through to the server, + * where it is an exact-array equality that selects a row stored as exactly + * `['a']` and nothing else (a live `mongod` cell is NOT MEASURED; the driver's + * compile face, the engine's shared comparand doors and MongoDB's query + * semantics are). No backend reads the array as the scalar the operator + * declares, so a view that PASSED the protocol selected nothing on every + * backend but MongoDB — and there it selected by a predicate the rule never + * wrote. The pins below carry both directions of * that arm, and the carve-outs (an absent value, the four valueless operators) keep their * own pins, because the #5685 side of this file is what stops a narrowing from * running on past the query path. diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9fbce69447f..3134c0bb0af 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -585,22 +585,37 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality - * comparand, which every backend answers". Re-measured by RUNNING all three - * backends: the lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare - * `{ field: value }` loop, which calls - * `assertCompilableComparand(column, '=', value)`; `'='` is in that file's - * `SCALAR_COMPARAND_OPERATORS`, `isBindableComparand(['a'])` is `false` (an array - * is none of the six accepted comparand types — `isAcceptedFilterComparand`, - * `filter-comparand-type.ts`), and the comparand is refused with the withheld - * `INVALID_FILTER` / 400 envelope. **The in-memory half is not uniform, and the - * difference is stated per backend rather than generalised:** `driver-memory` - * REFUSES the same shape in the same envelope — `match()` runs - * `assertFilterConditionShape`, whose implicit-equality arm throws on an array - * (`filter-refusal.ts`) — while `@objectstack/formula`'s - * `matchesFilterCondition` is the one backend that answers the shape at all, and - * it answers `false` for every row, a row whose stored value IS `['a']` - * included. Two refusals and one exclusion: no backend selects the row the - * author meant. So the ORIGINAL reading was the one that widened the accept + * comparand, which every backend answers". Re-measured by RUNNING the shipped + * backends, **each named rather than generalised — they do not agree, and the + * population is four, not three:** + * + * - **The SQL family REFUSES** — `driver-sql`, the `driver-turso` / + * `driver-sqlite-wasm` drivers built on it, and turso's `RemoteTransport`. The + * lowered `{ tags: ['a'] }` reaches `driver-sql`'s bare `{ field: value }` + * loop, which calls `assertCompilableComparand(column, '=', value)`; `'='` is + * in that file's `SCALAR_COMPARAND_OPERATORS`, `isBindableComparand(['a'])` is + * `false` (an array is none of the six accepted comparand types — + * `isAcceptedFilterComparand`, `filter-comparand-type.ts`), and the comparand + * is refused with the withheld `INVALID_FILTER` / 400 envelope. + * - **`driver-memory` REFUSES** the same shape in the same envelope — `match()` + * runs `assertFilterConditionShape`, whose implicit-equality arm throws on an + * array (`filter-refusal.ts`). + * - **`@objectstack/formula` EXCLUDES** — `matchesFilterCondition` answers + * `false` for every row, a row whose stored value IS `['a']` included. + * - **`driver-mongodb` ANSWERS** — the fourth shipped backend, and the one the + * earlier wording left out. `translateFilter({ tags: ['a'] })` emits + * `{"tags":["a"]}` unchanged (the array falls to the implicit-equality arm), + * and the engine's shared comparand doors — `normalizeFilterComparandTypes` + * and `assertListComparandShapes` — both pass the shape, so the server runs + * an exact-array equality: it selects a row stored as exactly `['a']` and + * nothing else. ⚠️ Measured at the driver's compile face, at those engine + * doors, and against MongoDB's query semantics through `mingo`; a live + * `mongod` cell is NOT MEASURED. + * + * So **no backend reads the array as the SCALAR the operator declares**: three + * refuse or exclude it outright, and the fourth returns rows for a different + * predicate — exact-array equality, and only on an array-valued field. So the + * ORIGINAL reading was the one that widened the accept * set past the query path; this arm pulls it back to what `value`'s own * `.describe()` has declared all along — 「every other operator takes a scalar」. * Direction set by objectui#9050's ruling C′ (「the differences are the From fee6908dd4d1acd474b6b3f6edc67df835943354 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 00:30:14 +0000 Subject: [PATCH 07/17] docs(spec): tighten three connectives in the re-counted backend prose (#19514) Wording only, on top of the population fix: the view.zod.ts paragraph had "So ... So" across a sentence boundary, the test docblock had "the opposite, and the four shipped backends" where the clause is an apposition rather than a second conjunct, and the changeset's closing sentence read as if MongoDB's rows were about the same data the rule asked for. No claim moves. The transpiled emit of view.zod.ts and view-filter-rule-value-shape.test.ts is still byte-identical to 536bc37fe0 (124,921 and 12,394 chars, same sha256), the entry file is untouched, and gen:migration-registry reproduces registry.ts byte-exact (blob b9363c8638). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...4-view-filter-rule-scalar-arm-and-icontains-comparand.md | 2 +- packages/spec/src/ui/view-filter-rule-value-shape.test.ts | 6 +++--- packages/spec/src/ui/view.zod.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index fe643c3b2d5..89f0f900cf5 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -23,7 +23,7 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ ⚠️ The MongoDB reading is taken at the driver's compile face, at the engine's shared comparand doors and against MongoDB's query semantics; a live `mongod` cell is NOT MEASURED. -**No backend reads the array as the scalar the operator declares.** Three refuse or exclude it outright, so **a stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. The fourth returns rows, but for a different predicate and only on an array-valued field, which reads as a true statement about the data too. +**No backend reads the array as the scalar the operator declares.** Three refuse or exclude it outright, so **a stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. The fourth returns rows — but for a different predicate, and only on an array-valued field, so it too reads as a true statement about data the rule never asked for. Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index 9d97f06f800..1b2df5ff750 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -17,7 +17,7 @@ * reasoning but a correction of one of its readings: an array on a scalar * operator was recorded here as accepted because it 「lowers to a bare * deep-equality comparand, which every backend answers」, and re-measurement - * found the opposite, and the four shipped backends do not agree: the SQL + * found the opposite — and the four shipped backends do not agree: the SQL * family (`driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built * on it, and turso's remote transport) and `driver-memory` REFUSE the comparand * with `INVALID_FILTER`; `@objectstack/formula`'s matcher EXCLUDES every row; @@ -28,8 +28,8 @@ * semantics are). No backend reads the array as the scalar the operator * declares, so a view that PASSED the protocol selected nothing on every * backend but MongoDB — and there it selected by a predicate the rule never - * wrote. The pins below carry both directions of - * that arm, and the carve-outs (an absent value, the four valueless operators) keep their + * wrote. The pins below carry both directions of that arm, and the carve-outs + * (an absent value, the four valueless operators) keep their * own pins, because the #5685 side of this file is what stops a narrowing from * running on past the query path. * diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 3134c0bb0af..74cf7252156 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -614,8 +614,8 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * * So **no backend reads the array as the SCALAR the operator declares**: three * refuse or exclude it outright, and the fourth returns rows for a different - * predicate — exact-array equality, and only on an array-valued field. So the - * ORIGINAL reading was the one that widened the accept + * predicate — exact-array equality, and only on an array-valued field. The + * ORIGINAL reading was therefore the one that widened the accept * set past the query path; this arm pulls it back to what `value`'s own * `.describe()` has declared all along — 「every other operator takes a scalar」. * Direction set by objectui#9050's ruling C′ (「the differences are the From 12e2eb3ec9b8d01a087a37cecb1a04b8cac0381b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:28:54 +0000 Subject: [PATCH 08/17] docs(spec): state each backend's answer in the present tense, per shape (#19514) Round-4 prose pass on the three #19514 entries, their changeset and the docblocks that carry the same sentences. Prose only: every touched .ts emits byte-identical output under removeComments, except the reason and acceptanceCriteria strings of the three entries (and their registry.ts mirror, regenerated by gen:migration-registry). - MongoDB: state its array-operand equality rule (the stored array equals the value OR holds it as an element; never a stored scalar), measured through mingo 7.2.4, with the live-mongod NOT MEASURED beside each statement. - History: present tense per backend; driver-memory's refusal is dated to 17.4.0, its published 17.3.0 answer is attributed to the review that ran it, and the unmeasured cells are marked NOT MEASURED. - defaultFilters: what the pinned objectui actually does per shape (record form and tuple array applied; bare string or number dropped, grid unfiltered; malformed rules refused), plus the filter: [] case. - icontains: every driver refuses both comparands; formula excludes. - Method sentences name the cells that were run and who ran them. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...rule-scalar-arm-and-icontains-comparand.md | 26 ++-- packages/spec/src/data/filter.zod.ts | 2 +- ...er-icontains-comparand-refused-at-parse.ts | 23 ++-- ....object-grid-default-filters-rule-array.ts | 26 ++-- ...lter-rule-scalar-operator-array-refused.ts | 80 ++++++----- packages/spec/src/migrations/registry.ts | 129 +++++++++++------- ...nt-object-grid-default-filters.pin.test.ts | 6 +- packages/spec/src/ui/component.test.ts | 9 +- packages/spec/src/ui/component.zod.ts | 25 ++-- .../ui/view-filter-rule-value-shape.test.ts | 34 +++-- packages/spec/src/ui/view.zod.ts | 80 +++++++---- 11 files changed, 272 insertions(+), 168 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index 89f0f900cf5..ba89431dfa1 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -10,26 +10,26 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ ## 1. A scalar operator carrying an ARRAY is refused -`ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so the entire scalar class was declared and never judged. +`ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so from the day the coupling landed until this change the entire scalar class was declared and not judged. -⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. Re-measured by RUNNING the shipped backends one at a time — there are **four**, and they do not agree: +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches are **four**, and at this release they do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): | backend | what it does with the lowered `{ tags: ['a'] }` | |:--|:--| | the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | | `driver-memory` | **REFUSES** — the same shape in the same envelope | | `@objectstack/formula` | **EXCLUDES** — `matchesFilterCondition` answers `false` for every row, a row whose stored value IS `['a']` included | -| `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server runs an **exact-array equality**: it selects a row stored as exactly `['a']` and nothing else | +| `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | -⚠️ The MongoDB reading is taken at the driver's compile face, at the engine's shared comparand doors and against MongoDB's query semantics; a live `mongod` cell is NOT MEASURED. +How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, the formula matcher, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. Run in this change's review: `driver-sql` on a live PostgreSQL 16 (refused before any SQL statement was emitted), `driver-sqlite-wasm`, and turso's remote transport over the repository's libsql stub. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. -**No backend reads the array as the scalar the operator declares.** Three refuse or exclude it outright, so **a stored view that passed the protocol selected nothing** — and unlike a 400, an exclusion reads as a true statement about the data. The fourth returns rows — but for a different predicate, and only on an array-valued field, so it too reads as a true statement about data the rule never asked for. +**None of the four reads the array as the scalar the operator declares.** Today three return no rows for it — the SQL family and `driver-memory` with a 400, the formula matcher with a silent exclusion that, unlike a 400, reads as a true statement about the data. `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it too reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. ## 2. The `icontains` comparands the platform's own table declares refused -`@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. Every backend answers those rows. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. +`@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. All five driver packages run both rows in their own suites, and the drivers re-run for this change — `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter` — each refuse both comparands with `INVALID_FILTER` / 400; the formula matcher does not refuse them, it answers `false` for every row. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. Scope is the one operator the table writes rows for: `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. @@ -37,7 +37,11 @@ One asymmetry between the two vocabularies, and it is a fact about them rather t ## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration -The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam. A record-form fallback got a silent success receipt and a 400 at render, with nothing in between to say which of the two keys was the problem. +The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: + +- the **record form** and the **AST tuple array** are lowered and **applied** as declared; +- a **bare string** or a **number** is **dropped** without a word, so the grid sends no filter and lists its rows unfiltered; +- a **list of malformed rules** is **refused** — on the wire with 400 `INVALID_FILTER`, or by the client before any request for the value shapes it judges itself. ⛔ **Narrowed, not retired.** Refusing the key outright is a removal of an accepted shape and needs its own ruling. The deprecation already stated in the description is unchanged: prefer `filter`. @@ -53,7 +57,13 @@ The key is described as *"Legacy base-filter fallback, read only when `filter` i | `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | | `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | -Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. Worth doing even where it looks unnecessary: **none of these shapes has ever returned the rows it declares** — the scalar-operator array returned nothing at all on every backend but MongoDB, and on MongoDB only the rows whose stored value is that exact array — so re-check what each view is supposed to show rather than assuming the old result set was correct. The one to read closest is a one-element array — `value: ['won']` on `equals` and `operator: 'in'` with `value: ['won']` select the same rows, and only the author knows which the metadata meant. +Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. What the rewrite changes on the page differs by row, so read them apart: + +- **The scalar-operator array.** At this release the SQL family and `driver-memory` answer it with a 400 and the formula matcher excludes every row; `driver-mongodb` returns the rows whose stored array equals the value or holds it as an element (a live `mongod` is NOT MEASURED). How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. +- **The two `icontains` comparands.** At this release each of the five driver packages answers both with a 400 and the formula matcher excludes every row; how earlier releases answered them is NOT MEASURED. +- **`defaultFilters`.** The record form and the AST tuple array were applied as declared in the pinned objectui, so for them the rewrite is a spelling change. A bare string or a number was dropped, so that grid has been listing its rows unfiltered — decide which rows it should show before writing the rule. Beside a non-empty `filter`, deleting `defaultFilters` is the whole migration; beside `filter: []` the grid reads `defaultFilters`, so move its rules onto `filter` rather than deleting them. + +The one to read closest is a one-element array: its two corrected spellings — `value: 'won'` on `equals`, and `operator: 'in'` with `value: ['won']` — select the same rows, so the result set cannot tell you which the metadata meant, and only the author knows. ## Who is affected, measured diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index c047ab9065d..ed4e74f6a2d 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1592,7 +1592,7 @@ function isPlainFilterNode(value: unknown): value is Record { * * ⛔ **Scoped to the one operator the table writes rows for.** `$contains` / * `$startsWith` / `$endsWith` / `$like` / `$ilike` have no such row and keep the - * answer they have always given; widening by analogy is the table's decision. + * answer they give today; widening by analogy is the table's decision. */ function checkFilterConditionComparands( node: unknown, diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts index 1763a5869a8..09b4beb362e 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts @@ -28,8 +28,12 @@ export const entry: SemanticMigration = { + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' - + 'code INVALID_FILTER and each requiring the refusal to name the operator. Every backend ' - + 'answers those rows. Nothing applied them at PARSE on either vocabulary, so the ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' + + 'driver packages run both rows in their own suites, and the drivers re-run for this ' + + 'change (driver-sql on SQLite, driver-memory, driver-mongodb\'s translateFilter) each ' + + 'refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse ' + + 'them, it answers false for every row. Nothing applied them at PARSE on either ' + + 'vocabulary, so the ' + 'protocol declared the refusal and then admitted the document that would hit it — the ' + 'declared-not-enforced shape ADR-0049 exists to close. ' + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' @@ -37,8 +41,8 @@ export const entry: SemanticMigration = { + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' - + '$endsWith, $like and $ilike have no such row and keep the answer they have always ' - + 'given, because widening by analogy is the table\'s decision and not a door\'s. ' + + '$endsWith, $like and $ilike have no such row and keep the answer they give today, ' + + 'because widening by analogy is the table\'s decision and not a door\'s. ' + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' @@ -55,11 +59,12 @@ export const entry: SemanticMigration = { 'Grep your authored filters for the case-insensitive contains operator in either ' + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + 'and the repair is to delete it, and a non-string one means either a missing pair of ' - + 'quotes or the wrong operator. A filter whose comparand was empty has been returning ' - + 'EVERY row, not zero, so a list that looked unfiltered was unfiltered — re-check what ' - + 'the view is supposed to show. A filter whose comparand was not a string has been ' - + 'answered with INVALID_FILTER at query time on every backend, so it has never returned ' - + 'rows at all. Both refusals now arrive at the authoring path with the same reason text ' + + 'quotes or the wrong operator. At this release neither comparand returns rows: each of ' + + 'the five driver packages answers both with INVALID_FILTER at query time, and the ' + + 'formula matcher answers false for every row. How earlier releases answered them was ' + + 'NOT measured, so re-check ' + + 'what the view is supposed to show rather than assuming the old result set was correct. ' + + 'Both refusals now arrive at the authoring path with the same reason text ' + 'the runtime gives, so the message an author reads is the same message wherever they ' + 'hit it.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts index ad50994b414..75644d1dc92 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts @@ -36,9 +36,14 @@ export const entry: SemanticMigration = { + 'refusal that sink can give was reachable from a document the protocol had just ' + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. An author who put ' - + 'the record form on the fallback got a silent success receipt and a 400 at render, with ' - + 'nothing in between to tell them which of the two keys was the problem. ' + + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' + + 'said nothing about what the grid would then do with the value, and in the objectui ' + + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' + + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' + + 'tuple array were lowered and applied as declared; a bare string or a number was ' + + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' + + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' + + 'client before any request for the value shapes it judges itself. ' + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + 'own ruling; the deprecation already stated in the description is unchanged and still ' @@ -55,9 +60,14 @@ export const entry: SemanticMigration = { + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. A grid that has been ' - + 'relying on a record-form defaultFilters was not being filtered by it — the lowering ' - + 'refused the shape — so re-check which rows the grid is supposed to show rather than ' - + 'assuming the displayed set was correct. Where both keys were authored, only filter was ' - + 'ever read: deleting defaultFilters is the whole migration.', + + 'an AST tuple array is refused one level in, at the first element. What to re-check ' + + 'depends on the shape that was there, as the objectui version this release pins treats ' + + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' + + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' + + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' + + 'show before writing the rule that selects them. A list of malformed rules was refused ' + + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' + + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' + + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' + + 'rules onto filter rather than deleting them.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 5bf11e4fef6..12ef50fa588 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -33,34 +33,41 @@ export const entry: SemanticMigration = { + 'The value key\'s own published description has declared this rule since #6227 — ' + '「every other operator takes a scalar」 — and the refinement that implements the ' + 'coupling returned early for every operator that is neither a list operator nor ' - + 'between, so the entire scalar class was declared and never judged. ' + + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. Re-measured for this entry, one backend at a time: the ' - + 'lowered node ' - + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' - + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' - + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the ' - + 'driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso\'s remote ' - + 'transport. The remaining backends are named one by one rather than generalised, ' - + 'because the FOUR that ship do not agree: driver-memory REFUSES the same shape in the ' - + 'same envelope (its assertFilterConditionShape throws on an array in the ' - + 'implicit-equality position); the formula matcher EXCLUDES every row, a row whose ' - + 'stored value IS the array included; and driver-mongodb ANSWERS — its translateFilter ' - + 'passes the array through unchanged and the engine\'s shared comparand doors ' + + 'which every backend answers」. The backends a lowered view rule reaches are four, and ' + + 'at this release they do not agree, so each is named rather than generalised. The SQL ' + + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + + 'of the six accepted comparand types the platform declares in ' + + 'ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld ' + + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' + + 'the implicit-equality position). The formula matcher EXCLUDES every row, a row whose ' + + 'stored value IS the array included. driver-mongodb ANSWERS: its translateFilter passes ' + + 'the array through unchanged and the engine\'s shared comparand doors ' + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' - + 'server runs an exact-array equality that selects a row stored as exactly that array ' - + 'and nothing else. That MongoDB reading is taken at the driver\'s compile face, at those ' - + 'engine doors and against MongoDB\'s query semantics; a live mongod instance was NOT ' - + 'measured. No backend reads the array as the SCALAR the operator declares. So the ' - + 'earlier reading was the one that widened the accept set past the query path, and a ' - + 'stored view carrying this shape PASSED the protocol and then selected nothing on ' - + 'every backend but MongoDB, where it selected by a predicate the rule never wrote. ' - + 'The narrowing mirrors the ' - + 'query path exactly and goes no further, which is the #5685 boundary this family has ' - + 'held since it was written. ' + + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' + + 'stored array equals the value or holds the value as one of its elements, and a row ' + + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + + 'the formula matcher, driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' + + 'MySQL and a live Turso server were NOT measured. None of the four reads the array as ' + + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' + + 'accept set past the query path: at this release a stored view carrying the shape gets ' + + 'a 400 from the SQL family and driver-memory and no rows from the formula matcher, and ' + + 'on MongoDB it selects by a predicate the rule never wrote. The narrowing refuses what ' + + 'the SQL family and driver-memory already refuse and goes no further, because a schema ' + + 'that refuses more than the query path does would reject stored metadata that runs ' + + 'correctly. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' @@ -74,13 +81,20 @@ export const entry: SemanticMigration = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite: on every backend but MongoDB such a rule has never ' - + 'returned filtered rows — it answered 400 INVALID_FILTER on the SQL family and on ' - + 'driver-memory, and the formula matcher excluded every row. On driver-mongodb it DID ' - + 'return rows, but not the ones the operator declares: the array is passed through to ' - + 'the server as an exact-array equality, so it selected only rows whose stored value is ' - + 'that exact array. Either way, re-check what the view is supposed to show rather than ' - + 'assuming the old result set was correct. A one-element array is the case to read closest: ' - + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' - + 'today, and only the author knows which the metadata meant.', + + 'Worth knowing before you rewrite, as each backend a lowered view rule reaches answers ' + + 'such a rule at this release: the SQL family and driver-memory answer it with 400 ' + + 'INVALID_FILTER, and the formula matcher excludes every row. driver-mongodb returns ' + + 'rows, but not the ones the operator declares: the array reaches the server unchanged, ' + + 'where MongoDB\'s equality rule for an array operand selects a row whose stored array ' + + 'equals the value or holds it as one of its elements, and not a row storing the scalar ' + + '— read at the driver\'s compile face and through mingo 7.2.4; a live mongod instance ' + + 'was NOT measured. If you are upgrading from 17.x, your release may have answered ' + + 'differently: driver-memory refuses the shape from 17.4.0, and its published 17.3.0 ' + + 'returned the row stored as the array (run in this change\'s review; which other rows ' + + 'it selected, nested arrays included, was NOT measured); whether any earlier SQL-family ' + + 'release answered the shape was NOT measured. Either way, re-check what the view is ' + + 'supposed to show rather than assuming the old result set was correct. A one-element ' + + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + + 'set cannot tell you which the metadata meant, and only the author knows.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b9363c86386..02017852645 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8572,8 +8572,12 @@ const step18: MigrationStep = { + 'untranslated): 「the differences are the protocol\'s to close」. The platform already ' + 'DECLARED both refusals, as data, in this package: FILTER_TEXT_CASES carries a ' + 'REJECTION row for an empty comparand and one for a non-string comparand, each with ' - + 'code INVALID_FILTER and each requiring the refusal to name the operator. Every backend ' - + 'answers those rows. Nothing applied them at PARSE on either vocabulary, so the ' + + 'code INVALID_FILTER and each requiring the refusal to name the operator. All five ' + + 'driver packages run both rows in their own suites, and the drivers re-run for this ' + + 'change (driver-sql on SQLite, driver-memory, driver-mongodb\'s translateFilter) each ' + + 'refuse both comparands with INVALID_FILTER / 400; the formula matcher does not refuse ' + + 'them, it answers false for every row. Nothing applied them at PARSE on either ' + + 'vocabulary, so the ' + 'protocol declared the refusal and then admitted the document that would hit it — the ' + 'declared-not-enforced shape ADR-0049 exists to close. ' + 'The narrowing is DERIVED from the table, not transcribed beside it: both doors call ' @@ -8581,8 +8585,8 @@ const step18: MigrationStep = { + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' - + '$endsWith, $like and $ilike have no such row and keep the answer they have always ' - + 'given, because widening by analogy is the table\'s decision and not a door\'s. ' + + '$endsWith, $like and $ilike have no such row and keep the answer they give today, ' + + 'because widening by analogy is the table\'s decision and not a door\'s. ' + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' + 'unjudged there; the $ dialect has no absent, so an explicit undefined in a comparand ' @@ -8599,11 +8603,12 @@ const step18: MigrationStep = { 'Grep your authored filters for the case-insensitive contains operator in either ' + 'spelling and read each comparand: an empty one means the condition was a placeholder ' + 'and the repair is to delete it, and a non-string one means either a missing pair of ' - + 'quotes or the wrong operator. A filter whose comparand was empty has been returning ' - + 'EVERY row, not zero, so a list that looked unfiltered was unfiltered — re-check what ' - + 'the view is supposed to show. A filter whose comparand was not a string has been ' - + 'answered with INVALID_FILTER at query time on every backend, so it has never returned ' - + 'rows at all. Both refusals now arrive at the authoring path with the same reason text ' + + 'quotes or the wrong operator. At this release neither comparand returns rows: each of ' + + 'the five driver packages answers both with INVALID_FILTER at query time, and the ' + + 'formula matcher answers false for every row. How earlier releases answered them was ' + + 'NOT measured, so re-check ' + + 'what the view is supposed to show rather than assuming the old result set was correct. ' + + 'Both refusals now arrive at the authoring path with the same reason text ' + 'the runtime gives, so the message an author reads is the same message wherever they ' + 'hit it.', }, @@ -10215,9 +10220,14 @@ const step18: MigrationStep = { + 'refusal that sink can give was reachable from a document the protocol had just ' + 'accepted. filter converged on the rule array with the rest of its family; this key was ' + 'not named by that ruling and kept the pre-convergence read-point shape, which left the ' - + 'block with one declared door and one undeclared door onto one seam. An author who put ' - + 'the record form on the fallback got a silent success receipt and a 400 at render, with ' - + 'nothing in between to tell them which of the two keys was the problem. ' + + 'block with one declared door and one undeclared door onto one seam. The parse receipt ' + + 'said nothing about what the grid would then do with the value, and in the objectui ' + + 'version this release pins that depended on the shape: ObjectGrid lowers defaultFilters ' + + 'through toFilterNode whenever filter lowers to nothing, so a record form and an AST ' + + 'tuple array were lowered and applied as declared; a bare string or a number was ' + + 'dropped without a word, so the grid sent no filter and listed its rows unfiltered; and ' + + 'a list of malformed rules was refused — on the wire with 400 INVALID_FILTER, or by the ' + + 'client before any request for the value shapes it judges itself. ' + '⛔ This entry is a NARROWING and deliberately not a retirement. Refusing the key ' + 'outright — the other arm the finding offered — removes an accepted shape and needs its ' + 'own ruling; the deprecation already stated in the description is unchanged and still ' @@ -10234,11 +10244,16 @@ const step18: MigrationStep = { + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' + 'that array raises no issue at the key; a record form is refused AT defaultFilters with ' + 'the conversion table and a worked rewrite built from the keys that were written, and ' - + 'an AST tuple array is refused one level in, at the first element. A grid that has been ' - + 'relying on a record-form defaultFilters was not being filtered by it — the lowering ' - + 'refused the shape — so re-check which rows the grid is supposed to show rather than ' - + 'assuming the displayed set was correct. Where both keys were authored, only filter was ' - + 'ever read: deleting defaultFilters is the whole migration.', + + 'an AST tuple array is refused one level in, at the first element. What to re-check ' + + 'depends on the shape that was there, as the objectui version this release pins treats ' + + 'it. A record form or an AST tuple array was lowered and applied, so for those the ' + + 'rewrite is a spelling change. A bare string or a number was dropped by that lowering, ' + + 'so the grid has been listing its rows unfiltered — decide which rows it is supposed to ' + + 'show before writing the rule that selects them. A list of malformed rules was refused ' + + 'when the grid loaded. Where both keys are authored, that grid reads defaultFilters only ' + + 'when filter lowers to nothing: beside a non-empty filter, deleting defaultFilters is ' + + 'the whole migration; beside filter: [] the grid reads defaultFilters, so move those ' + + 'rules onto filter rather than deleting them.', }, { id: 'object-index-unknown-keys-refused', @@ -13082,34 +13097,41 @@ const step18: MigrationStep = { + 'The value key\'s own published description has declared this rule since #6227 — ' + '「every other operator takes a scalar」 — and the refinement that implements the ' + 'coupling returned early for every operator that is neither a list operator nor ' - + 'between, so the entire scalar class was declared and never judged. ' + + 'between, so the entire scalar class was declared and, from #6227 until this change, ' + + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. Re-measured for this entry, one backend at a time: the ' - + 'lowered node ' - + 'reaches driver-sql\'s bare field-value loop, which asserts the comparand against its ' - + 'own SCALAR_COMPARAND_OPERATORS set; an array is none of the six accepted comparand ' - + 'types the platform declares in ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the ' - + 'comparand is refused with the withheld INVALID_FILTER / 400 envelope — and with it the ' - + 'driver-turso and driver-sqlite-wasm drivers built on driver-sql, and turso\'s remote ' - + 'transport. The remaining backends are named one by one rather than generalised, ' - + 'because the FOUR that ship do not agree: driver-memory REFUSES the same shape in the ' - + 'same envelope (its assertFilterConditionShape throws on an array in the ' - + 'implicit-equality position); the formula matcher EXCLUDES every row, a row whose ' - + 'stored value IS the array included; and driver-mongodb ANSWERS — its translateFilter ' - + 'passes the array through unchanged and the engine\'s shared comparand doors ' + + 'which every backend answers」. The backends a lowered view rule reaches are four, and ' + + 'at this release they do not agree, so each is named rather than generalised. The SQL ' + + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + + 'of the six accepted comparand types the platform declares in ' + + 'ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, so the comparand is refused with the withheld ' + + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' + + 'the implicit-equality position). The formula matcher EXCLUDES every row, a row whose ' + + 'stored value IS the array included. driver-mongodb ANSWERS: its translateFilter passes ' + + 'the array through unchanged and the engine\'s shared comparand doors ' + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' - + 'server runs an exact-array equality that selects a row stored as exactly that array ' - + 'and nothing else. That MongoDB reading is taken at the driver\'s compile face, at those ' - + 'engine doors and against MongoDB\'s query semantics; a live mongod instance was NOT ' - + 'measured. No backend reads the array as the SCALAR the operator declares. So the ' - + 'earlier reading was the one that widened the accept set past the query path, and a ' - + 'stored view carrying this shape PASSED the protocol and then selected nothing on ' - + 'every backend but MongoDB, where it selected by a predicate the rule never wrote. ' - + 'The narrowing mirrors the ' - + 'query path exactly and goes no further, which is the #5685 boundary this family has ' - + 'held since it was written. ' + + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' + + 'stored array equals the value or holds the value as one of its elements, and a row ' + + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + + 'the formula matcher, driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' + + 'MySQL and a live Turso server were NOT measured. None of the four reads the array as ' + + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' + + 'accept set past the query path: at this release a stored view carrying the shape gets ' + + 'a 400 from the SQL family and driver-memory and no rows from the formula matcher, and ' + + 'on MongoDB it selects by a predicate the rule never wrote. The narrowing refuses what ' + + 'the SQL family and driver-memory already refuse and goes no further, because a schema ' + + 'that refuses more than the query path does would reject stored metadata that runs ' + + 'correctly. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' @@ -13123,15 +13145,22 @@ const step18: MigrationStep = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite: on every backend but MongoDB such a rule has never ' - + 'returned filtered rows — it answered 400 INVALID_FILTER on the SQL family and on ' - + 'driver-memory, and the formula matcher excluded every row. On driver-mongodb it DID ' - + 'return rows, but not the ones the operator declares: the array is passed through to ' - + 'the server as an exact-array equality, so it selected only rows whose stored value is ' - + 'that exact array. Either way, re-check what the view is supposed to show rather than ' - + 'assuming the old result set was correct. A one-element array is the case to read closest: ' - + 'value: ["won"] on equals and operator: "in" with value: ["won"] select the same rows ' - + 'today, and only the author knows which the metadata meant.', + + 'Worth knowing before you rewrite, as each backend a lowered view rule reaches answers ' + + 'such a rule at this release: the SQL family and driver-memory answer it with 400 ' + + 'INVALID_FILTER, and the formula matcher excludes every row. driver-mongodb returns ' + + 'rows, but not the ones the operator declares: the array reaches the server unchanged, ' + + 'where MongoDB\'s equality rule for an array operand selects a row whose stored array ' + + 'equals the value or holds it as one of its elements, and not a row storing the scalar ' + + '— read at the driver\'s compile face and through mingo 7.2.4; a live mongod instance ' + + 'was NOT measured. If you are upgrading from 17.x, your release may have answered ' + + 'differently: driver-memory refuses the shape from 17.4.0, and its published 17.3.0 ' + + 'returned the row stored as the array (run in this change\'s review; which other rows ' + + 'it selected, nested arrays included, was NOT measured); whether any earlier SQL-family ' + + 'release answered the shape was NOT measured. Either way, re-check what the view is ' + + 'supposed to show rather than assuming the old result set was correct. A one-element ' + + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' + + 'set cannot tell you which the metadata meant, and only the author knows.', }, { id: 'wait-node-event-config-required', diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts index d2e95791c97..b3af9fc61df 100644 --- a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts +++ b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts @@ -11,7 +11,11 @@ * pre-convergence `z.unknown()`, so the block had one declared door and one * undeclared door onto one seam: a bare string, a number, a MongoDB-style * record, an ObjectQL AST tuple array and a list of malformed rules all parsed - * here, and each of them is a refusal waiting at the lowering. + * here. At the pinned objectui (`.objectui-sha` `87af769e9a`) the lowering + * treats them three ways, per shape: the record form and the tuple array are + * lowered and APPLIED as declared; a bare string or a number is DROPPED, so the + * grid sends no filter and lists its rows unfiltered; and a list of malformed + * rules is REFUSED, on the wire or by the client before any request. * * These pins hold the two keys EQUAL rather than transcribing a list of shapes * — the equality is the rule, and a list would go stale the next time `filter` diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 431b17bb839..6dfaabe9adf 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -3191,10 +3191,11 @@ describe('#7751 — object-* block props schemas', () => { // The key is still honoured and still parses; what changed is that it now // carries `filter`'s own declaration — the same value in the same role, // read through the same lowering sink — instead of `z.unknown()`. The AST - // tuple array this pin used to spell was one of the five shapes that sink - // refuses, so the old fixture was pinning a receipt for a filter that never - // ran. Its refusal is pinned below, and in full at - // `component-object-grid-default-filters.pin.test.ts`. + // tuple array this pin used to spell is one the pinned objectui grid + // APPLIES (`toFilterNode` passes it through and `parseFilterAST` accepts + // it), so its refusal here is a spelling change for the author, not the + // repair of a filter that failed. Its refusal is pinned below, and in full + // at `component-object-grid-default-filters.pin.test.ts`. const rules = [{ field: 'status', operator: 'equals', value: 'open' }]; const parsed = ComponentPropsMap['object-grid'].parse({ objectName: 'showcase_task', diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 9aa7c654900..80497d9c718 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2718,15 +2718,22 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * [#19514] The legacy base-filter fallback — the SAME value in the SAME role * as `filter` above, so it carries the same declaration. * - * Its own description has always said "read only when `filter` is absent", - * which is a statement that the two keys hold one kind of value: objectui's - * `ObjectGrid` reads this one through the same lowering sink it reads `filter` - * through, so every refusal that sink can give is reachable from a document - * that passed the protocol. While `filter` was narrowed to the rule array and - * this stayed `z.unknown()`, the block had a declared door and an undeclared - * one onto the same seam — a bare string, a number, a MongoDB-style record and - * an ObjectQL AST tuple array all parsed here, and the author's receipt said - * nothing about the 400 waiting for them. + * Its own description has said "read only when `filter` is absent" since the + * key entered this map (#7751), which is a statement that the two keys hold + * one kind of value: objectui's `ObjectGrid` reads this one through the same + * lowering sink it reads `filter` through, so every refusal that sink can + * give is reachable from a document that passed the protocol. While `filter` + * was narrowed to the rule array and this stayed `z.unknown()`, the block had + * a declared door and an undeclared one onto the same seam — a bare string, a + * number, a MongoDB-style record and an ObjectQL AST tuple array all parsed + * here, and the author's receipt said nothing about what the grid would do + * with them. At the pinned objectui (`.objectui-sha` `87af769e9a`, + * `ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record + * form and the tuple array are lowered and APPLIED as declared; a bare string + * or a number is DROPPED, so the grid sends no filter and lists its rows + * unfiltered; and a list of malformed rules is REFUSED — on the wire with + * 400 `INVALID_FILTER`, or by the client before any request for the value + * shapes it judges itself. * * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this * could have taken and it is a REMOVAL of an accepted shape, which needs its diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index 1b2df5ff750..f85a6b3aac0 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -17,18 +17,20 @@ * reasoning but a correction of one of its readings: an array on a scalar * operator was recorded here as accepted because it 「lowers to a bare * deep-equality comparand, which every backend answers」, and re-measurement - * found the opposite — and the four shipped backends do not agree: the SQL - * family (`driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built - * on it, and turso's remote transport) and `driver-memory` REFUSE the comparand - * with `INVALID_FILTER`; `@objectstack/formula`'s matcher EXCLUDES every row; - * and `driver-mongodb` ANSWERS — it passes the array through to the server, - * where it is an exact-array equality that selects a row stored as exactly - * `['a']` and nothing else (a live `mongod` cell is NOT MEASURED; the driver's - * compile face, the engine's shared comparand doors and MongoDB's query - * semantics are). No backend reads the array as the scalar the operator - * declares, so a view that PASSED the protocol selected nothing on every - * backend but MongoDB — and there it selected by a predicate the rule never - * wrote. The pins below carry both directions of that arm, and the carve-outs + * found the opposite. The four backends a lowered view rule reaches do not + * agree at this change: the SQL family (`driver-sql`, the `driver-turso` / + * `driver-sqlite-wasm` drivers built on it, and turso's remote transport) and + * `driver-memory` REFUSE the comparand with `INVALID_FILTER`; + * `@objectstack/formula`'s matcher EXCLUDES every row; and `driver-mongodb` + * ANSWERS — it passes the array through to the server, where MongoDB's + * equality rule for an array operand selects a row whose stored array equals + * `['a']` or holds `['a']` as an element, and not a row storing the scalar + * `'a'` (read at the driver's compile face, at the engine's shared comparand + * doors and through mingo 7.2.4; a live `mongod` cell is NOT MEASURED). None + * of the four reads the array as the scalar the operator declares, so a view + * carrying it gets a 400 or no rows on three of them and, on MongoDB, rows + * chosen by a predicate the rule never wrote. The pins below carry both + * directions of that arm, and the carve-outs * (an absent value, the four valueless operators) keep their * own pins, because the #5685 side of this file is what stops a narrowing from * running on past the query path. @@ -184,9 +186,11 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the ['not_in + empty array', { field: 'f', operator: 'not_in', value: [] }], ['between + pair', { field: 'f', operator: 'between', value: [1, 2] }], ['between + ISO date pair', { field: 'd', operator: 'between', value: ['2024-01-01', '2024-12-31'] }], - // A string operator carrying a number: no backend refuses it. (`icontains` - // is the one exception and it is the TABLE's row, not an analogy — see the - // comparand pins below.) + // A string operator carrying a number: none of the four backends a lowered + // view rule reaches refuses it (`driver-sql` on SQLite and `driver-memory` + // answer it, `driver-mongodb` compiles it, the formula matcher excludes + // every row). (`icontains` is the one exception and it is the TABLE's row, + // not an analogy — see the comparand pins below.) ['contains + number', { field: 'f', operator: 'contains', value: 5 }], ['starts_with + number', { field: 'f', operator: 'starts_with', value: 5 }], // Ordering operators take a scalar of any declared type (#5685 widened these). diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 74cf7252156..cfe4e9d1b48 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -568,26 +568,28 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * module docblock names this schema as the reachable authoring source of the * defect. * - * ## Why this mirrors the query path EXACTLY, and refuses to go further + * ## Why this mirrors the query path's refusals, and refuses to go further * * The first two checks below are `assertListComparandShapes`' constraints, one * for one: `$in`/`$nin` must be an array, `$between` must be a 2-array. The * third — a SCALAR operator handed an array — is `driver-sql`'s * `assertCompilableComparand` scalar arm, and it is here for the same reason the - * other two are: the query path refuses it, so refusing it at authoring time - * moves the refusal to the moment the author can still act on it. Nothing beyond - * those is judged, deliberately — #5685 already ruled on the opposite error, - * where `FieldOperatorsSchema` declared `$gt` as `number | Date | FieldReference` - * while every first-party producer put an ISO STRING there; the schema was ruled - * the wrong side and widened to match the runtime. A publish-time gate refusing + * other two are: the query path refuses it (on the SQL family and + * `driver-memory`; all four backends are named below), so refusing it at + * authoring time moves the refusal to the moment the author can still act on + * it. Nothing beyond those is judged, deliberately — #5685 already ruled on the + * opposite error, where `FieldOperatorsSchema` declared `$gt` as + * `number | Date | FieldReference` while every first-party producer put an ISO + * STRING there; the schema was ruled the wrong side and widened to match the + * runtime. A publish-time gate refusing * more than the query path refuses would re-create that mismatch pointing the * other way, and would reject stored metadata that executes correctly today. * * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality - * comparand, which every backend answers". Re-measured by RUNNING the shipped - * backends, **each named rather than generalised — they do not agree, and the - * population is four, not three:** + * comparand, which every backend answers". **The backends a lowered view rule + * reaches are four, not three, and at this change they do not agree** — so + * each is named rather than generalised, in the present tense: * * - **The SQL family REFUSES** — `driver-sql`, the `driver-turso` / * `driver-sqlite-wasm` drivers built on it, and turso's `RemoteTransport`. The @@ -599,25 +601,41 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * is refused with the withheld `INVALID_FILTER` / 400 envelope. * - **`driver-memory` REFUSES** the same shape in the same envelope — `match()` * runs `assertFilterConditionShape`, whose implicit-equality arm throws on an - * array (`filter-refusal.ts`). + * array (`filter-refusal.ts`). That refusal first shipped in + * `@objectstack/driver-memory@17.4.0`; published 17.3.0 returned the row + * stored as `['a']` (run in this change's review; which other rows it + * selected is NOT MEASURED). * - **`@objectstack/formula` EXCLUDES** — `matchesFilterCondition` answers * `false` for every row, a row whose stored value IS `['a']` included. - * - **`driver-mongodb` ANSWERS** — the fourth shipped backend, and the one the - * earlier wording left out. `translateFilter({ tags: ['a'] })` emits + * - **`driver-mongodb` ANSWERS** — the fourth backend, and the one the earlier + * wording left out. `translateFilter({ tags: ['a'] })` emits * `{"tags":["a"]}` unchanged (the array falls to the implicit-equality arm), * and the engine's shared comparand doors — `normalizeFilterComparandTypes` - * and `assertListComparandShapes` — both pass the shape, so the server runs - * an exact-array equality: it selects a row stored as exactly `['a']` and - * nothing else. ⚠️ Measured at the driver's compile face, at those engine - * doors, and against MongoDB's query semantics through `mingo`; a live - * `mongod` cell is NOT MEASURED. - * - * So **no backend reads the array as the SCALAR the operator declares**: three - * refuse or exclude it outright, and the fourth returns rows for a different - * predicate — exact-array equality, and only on an array-valued field. The - * ORIGINAL reading was therefore the one that widened the accept + * and `assertListComparandShapes` — both pass the shape, so the server + * applies MongoDB's equality rule for an array operand: a row matches when + * its stored array EQUALS `['a']` or HOLDS `['a']` as an element, and a row + * storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, + * `['a','b']`, `['b','a']`, `[['a'],'x']`, `[['a']]` and `'b'`, selects + * `['a']`, `[['a'],'x']` and `[['a']]`). ⚠️ Read at the driver's compile + * face, at those engine doors and through `mingo`; a live `mongod` cell is + * NOT MEASURED. + * + * Method: `driver-sql` on SQLite, `driver-memory`, the formula matcher, + * `driver-mongodb`'s `translateFilter` and `mingo` were each run on the + * lowered `{ tags: ['a'] }` beside a scalar and an `$in` control; this + * change's review also ran `driver-sql` on a live PostgreSQL 16 (refused with + * zero SQL statements emitted), `driver-sqlite-wasm`, and turso's remote + * transport over the repository's libsql stub. MySQL, a live Turso server and + * a live `mongod` are NOT MEASURED, and so is whether any SQL-family release + * before this change answered the shape. + * + * So **none of the four reads the array as the SCALAR the operator + * declares**: three refuse or exclude it outright, and `driver-mongodb` returns + * rows for a different predicate — array equality, and only on an array-valued + * field (live `mongod` NOT MEASURED). The ORIGINAL reading was therefore the one that widened the accept * set past the query path; this arm pulls it back to what `value`'s own - * `.describe()` has declared all along — 「every other operator takes a scalar」. + * `.describe()` has declared since #6227 — 「every other operator takes a + * scalar」. * Direction set by objectui#9050's ruling C′ (「the differences are the * protocol's to close」); prescription registered as the ADR-0087 entry * `view-filter-rule-scalar-operator-array-refused`. @@ -629,10 +647,12 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * as many words. Arity is not this check's business for membership; only "is it * a list at all". * - **A string operator carrying a number** (`contains: 5`). Lowers to - * `$contains: 5`; no backend refuses it. `icontains` is the ONE exception and - * it is not an analogy — `FILTER_TEXT_CASES` declares that comparand - * refused as data, and {@link checkViewFilterRuleTextComparand} below answers - * those rows and only those rows. + * `$contains: 5`; none of the four backends above refuses it (`driver-sql` + * on SQLite and `driver-memory` answer it, `driver-mongodb` compiles it to a + * `$regex`, the formula matcher excludes every row). `icontains` is the ONE + * exception and it is not an analogy — `FILTER_TEXT_CASES` declares that + * comparand refused as data, and {@link checkViewFilterRuleTextComparand} + * below answers those rows and only those rows. * - **A unary operator carrying a value** (`is_empty: ''`, and `is_empty: []`). * The null predicates take their direction from the operator NAME — * `convertComparison` maps them to `{ $null: true|false }` and ignores the @@ -727,7 +747,7 @@ function checkViewFilterRuleValueShape( // Everything left takes a SCALAR — `value`'s own `.describe()` has said so // since #6227 and nothing judged it, so the whole class rode through. The two // carve-outs are the ones the query path itself makes: an ABSENT value (the - // key is optional, and unary operators never carry one) and the valueless + // key is optional, and a unary operator need not carry one) and the valueless // operators, whose `value` position is discarded by `convertComparison`. if (value === undefined) return; if ((VIEW_FILTER_VALUELESS_OPERATORS as readonly string[]).includes(operator)) return; @@ -777,7 +797,7 @@ function checkViewFilterRuleValueShape( * one issue: an author who wrote `icontains: ['a']` is told about the SHAPE, * which is what they have to fix first. * 3. **THE SIBLING OPERATORS.** `contains` / `starts_with` / `ends_with` have no - * row in the table and keep the answer they have always given. Widening by + * row in the table and keep the answer they give today. Widening by * analogy is the table's decision to make, never this door's. * * ## The `$` twin is named here, not in the contract half From d3a4fb07789990af4e51a8cf39199f1e8f27b772 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 02:52:32 +0000 Subject: [PATCH 09/17] docs(spec): bound the icontains test docblock's backend claim to the drivers (#19514) The docblock said every backend "answered" the two icontains rejection rows; the formula matcher does not refuse them, it excludes every row. Present tense, drivers named by count, formula named. Comment only: the file's removeComments emit is byte-identical. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/filter-icontains-parse-door.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/filter-icontains-parse-door.test.ts b/packages/spec/src/data/filter-icontains-parse-door.test.ts index 82b1e04d950..c8f417d472a 100644 --- a/packages/spec/src/data/filter-icontains-parse-door.test.ts +++ b/packages/spec/src/data/filter-icontains-parse-door.test.ts @@ -7,9 +7,10 @@ * * `filter-text-conformance.test.ts` proves the table is internally honest and * `filter-text-comparand.test.ts` proves the published predicate answers it. - * Neither of those reaches a SCHEMA: until this round the platform declared the - * two refusals as data, every backend answered them, and both authoring doors - * admitted the document anyway — `FilterConditionSchema` and + * Neither of those reaches a SCHEMA. The platform declares the two refusals as + * data, and each of the five driver packages refuses both comparands at query + * time (the formula matcher excludes every row instead); yet until this round + * both authoring doors admitted the document — `FilterConditionSchema` and * `ViewFilterRuleSchema` both said `success: true` for an empty comparand and * for a numeric one. That is the declared-not-enforced shape ADR-0049 closes. * From 50321c9f6e9c3bf9c516f342343b4935e4cb14c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:06:04 +0000 Subject: [PATCH 10/17] docs(spec): spell the objectui pin citations as dated at-pin records (#19514) check:objectui-pin-citations refuses a sha in neither recognised spelling. The three new citations describe what the lowering does AT that pin, so they take the dated-record spelling (".objectui-sha pin SHA"), not the checked one. Comment and changeset prose only; removeComments emit of both .ts files is byte-identical. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...514-view-filter-rule-scalar-arm-and-icontains-comparand.md | 2 +- .../src/ui/component-object-grid-default-filters.pin.test.ts | 2 +- packages/spec/src/ui/component.zod.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index ba89431dfa1..ae986c16a9e 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -37,7 +37,7 @@ One asymmetry between the two vocabularies, and it is a fact about them rather t ## 3. `object-grid`'s `defaultFilters` carries `filter`'s declaration -The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: +The key is described as *"Legacy base-filter fallback, read only when `filter` is absent"* — the same value in the same role as `filter`, read through the same lowering sink. `filter` converged on the `ViewFilterRule` array with the rest of its family; this key was not named by that ruling and kept `z.unknown()`, so the block had one declared door and one undeclared door onto one seam, and the parse receipt said nothing about what the grid would then do with the value. In the objectui version this release pins (`.objectui-sha` pin `87af769e9a`), `ObjectGrid` lowers `defaultFilters` through `toFilterNode` whenever `filter` lowers to nothing, and what that does depends on the shape: - the **record form** and the **AST tuple array** are lowered and **applied** as declared; - a **bare string** or a **number** is **dropped** without a word, so the grid sends no filter and lists its rows unfiltered; diff --git a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts index b3af9fc61df..8f70f077601 100644 --- a/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts +++ b/packages/spec/src/ui/component-object-grid-default-filters.pin.test.ts @@ -11,7 +11,7 @@ * pre-convergence `z.unknown()`, so the block had one declared door and one * undeclared door onto one seam: a bare string, a number, a MongoDB-style * record, an ObjectQL AST tuple array and a list of malformed rules all parsed - * here. At the pinned objectui (`.objectui-sha` `87af769e9a`) the lowering + * here. At the objectui `.objectui-sha` pin `87af769e9a` the lowering * treats them three ways, per shape: the record form and the tuple array are * lowered and APPLIED as declared; a bare string or a number is DROPPED, so the * grid sends no filter and lists its rows unfiltered; and a list of malformed diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 80497d9c718..b7b94eac7dd 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2727,8 +2727,8 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * a declared door and an undeclared one onto the same seam — a bare string, a * number, a MongoDB-style record and an ObjectQL AST tuple array all parsed * here, and the author's receipt said nothing about what the grid would do - * with them. At the pinned objectui (`.objectui-sha` `87af769e9a`, - * `ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record + * with them. At the objectui `.objectui-sha` pin `87af769e9a` + * (`ObjectGrid.tsx` → `toFilterNode`) that depends on the shape: the record * form and the tuple array are lowered and APPLIED as declared; a bare string * or a number is DROPPED, so the grid sends no filter and lists its rows * unfiltered; and a list of malformed rules is REFUSED — on the wire with From cb4dc9303de52987845624372fa36626b9f32af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:35:58 +0000 Subject: [PATCH 11/17] docs(spec): bound the scalar entry's closing "400s" to the backends that give it (#19514) The entry's last sentence said a re-saved view would otherwise store "a filter that 400s"; on driver-mongodb it does not 400. Name the two that do. Only this entry's reason string and its registry.ts mirror move (gen:migration-registry). Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18.view-filter-rule-scalar-operator-array-refused.ts | 3 ++- packages/spec/src/migrations/registry.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 12ef50fa588..6d672680601 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -74,7 +74,8 @@ export const entry: SemanticMigration = { + 'two on equals has no honest single value, and picking the first is a different ' + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' - + 'storing a filter that 400s. ADR-0049 / ADR-0087 / ADR-0112.', + + 'storing a filter that the SQL family and driver-memory answer with a 400. ADR-0049 / ' + + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + 'none of in / not_in / between / the four unary operators and whose value is an array, ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 02017852645..b09f395212d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13138,7 +13138,8 @@ const step18: MigrationStep = { + 'two on equals has no honest single value, and picking the first is a different ' + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' - + 'storing a filter that 400s. ADR-0049 / ADR-0087 / ADR-0112.', + + 'storing a filter that the SQL family and driver-memory answer with a 400. ADR-0049 / ' + + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + 'none of in / not_in / between / the four unary operators and whose value is an array, ' From a17615e3adeb4fff08d88c6f08aea47dc3c7ed5c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:11:18 +0000 Subject: [PATCH 12/17] fix(spec): drop the scalar-arm message's query-path tail, false on driver-mongodb (#19514) The scalar arm's authoring-time message ended "This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER)." That holds on the SQL family and driver-memory; driver-mongodb's query path answers the shape. The sentence is deleted, not rewritten; the rest of the message still says what is wrong and how to fix it. The list and range messages keep their own tails, which predate this change. The scalar-arm pin asserted the fragment "400 INVALID_FILTER", which lived only in the deleted sentence; that one expect is removed and its comment trimmed. The two assertions on the prescription stay. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/ui/view-filter-rule-value-shape.test.ts | 3 +-- packages/spec/src/ui/view.zod.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index f85a6b3aac0..33820bb985b 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -255,10 +255,9 @@ describe('#19514 — the scalar arm, in both directions', () => { // diagnoses, and an author must be able to tell which one fired. expect(issue.message).not.toContain('requires an ARRAY of values'); expect(issue.message).not.toContain('requires a [min, max] value array'); - // The refusal carries what to DO, and the query-path code to match it against. + // The refusal carries what to DO. expect(issue.message).toContain('to compare against one value'); expect(issue.message).toContain('or use "in" to test membership of the list'); - expect(issue.message).toContain('400 INVALID_FILTER'); }); it('prescribes the author OWN first member, not a canned example', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index cfe4e9d1b48..0d1a616b1cb 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -762,8 +762,7 @@ function checkViewFilterRuleValueShape( + `"${VIEW_FILTER_PAIR_VALUE_OPERATORS.join('" / "')}" takes a [min, max] range — write ` + `${value.length > 0 ? previewFilterValue(value[0]) : 'the value to compare against'} ` + `to compare against one value, or use "${VIEW_FILTER_LIST_VALUE_OPERATORS[0]}" to test ` - + `membership of the list. This is refused at authoring time because the query path ` - + `refuses it too (400 INVALID_FILTER).`, + + `membership of the list.`, }); } From e198e63695ae9409eb50157305935cc67b2d62e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:47:19 +0000 Subject: [PATCH 13/17] fix(spec): drop the $-dialect icontains message's query-path tail (#19514) The seat's N1 ruling, in parity with the scalar arm's Q1 (B): the `$icontains` refusal no longer ends "This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER)." The reason clause it seats already carries the declared code, so the message still names INVALID_FILTER. No assertion is added for the tail's absence. The same file's docblock loses the "one operator the table writes rows for" / "no such row" claim: FILTER_TEXT_CASES writes rows for seven operators. Deletion only. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- packages/spec/src/data/filter.zod.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index ed4e74f6a2d..c8e69de2093 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1590,8 +1590,8 @@ function isPlainFilterNode(value: unknown): value is Record { * shape `FILTER_COMPARAND_TYPE_CASES` calls the mongo silent-edit worst cell. * The view vocabulary, which DOES have an absent, carves it out on its own side. * - * ⛔ **Scoped to the one operator the table writes rows for.** `$contains` / - * `$startsWith` / `$endsWith` / `$like` / `$ilike` have no such row and keep the + * ⛔ `$contains` / + * `$startsWith` / `$endsWith` / `$like` / `$ilike` keep the * answer they give today; widening by analogy is the table's decision. */ function checkFilterConditionComparands( @@ -1619,8 +1619,7 @@ function checkFilterConditionComparands( code: 'custom', path: [...path, key, op], message: - `The ${textComparandRefusalReason(key, op, comparand)}. This is refused at ` - + `authoring time because the query path refuses it too (400 INVALID_FILTER).`, + `The ${textComparandRefusalReason(key, op, comparand)}.`, }); continue; } From 52f61961999955fdf425d8058da53b88fbdbfa1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 05:47:34 +0000 Subject: [PATCH 14/17] docs(spec): delete the round-4 over-claims from the reversal prose (#19514) Deletion only; no sentence is added and no measurement is restated. - The per-backend answer for the scalar-operator array is now stated only where the text is explicitly about the lowered equality node; the class-wide statements (the scalar entry's acceptanceCriteria, its "goes no further" sentence and closing clause, the changeset's scalar-operator-array bullet under FROM -> TO, the view docblock's "the query path refuses it" clause and the test docblock's per-backend passage) are gone. - The formula matcher is no longer counted among the backends a lowered view rule reaches, and no backend count remains. - The "only operator with table rows" claims are gone from the changeset, the icontains entry and the view docblock. - The scalar entry's surface keeps "every carrier of ViewFilterRuleSchema" and drops the enumeration after it (the seat's one authorised surface edit). - The icontains Done-when drops its "same message wherever" clause, the changeset drops "Each refusal carries its own prescription ...", and component.zod.ts drops the "ACCEPT SET back to the one the consumer already honours" clause. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...rule-scalar-arm-and-icontains-comparand.md | 13 +++-- ...er-icontains-comparand-refused-at-parse.ts | 8 ++-- ...lter-rule-scalar-operator-array-refused.ts | 39 +++++---------- packages/spec/src/migrations/registry.ts | 47 ++++++------------- packages/spec/src/ui/component.zod.ts | 3 +- .../ui/view-filter-rule-value-shape.test.ts | 20 ++------ packages/spec/src/ui/view.zod.ts | 31 +++++------- 7 files changed, 51 insertions(+), 110 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index ae986c16a9e..d61f031d406 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -12,18 +12,17 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ `ViewFilterRuleSchema.value` has carried this sentence in its published description since the operator/value coupling landed: *the accepted SHAPE depends on the operator: `in` / `not_in` take an array, `between` takes exactly [min, max], **every other operator takes a scalar**.* The refinement that implements the coupling returned early for every operator that was neither a list operator nor `between`, so from the day the coupling landed until this change the entire scalar class was declared and not judged. -⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches are **four**, and at this release they do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): +⚠️ **This reverses a reading the code recorded**, and the reversal is the substance. The scalar-operator array was listed as deliberately accepted because it *"lowers to a bare `{ field: value }` deep-equality comparand, which every backend answers"*. The backends a lowered view rule reaches at this release do not agree — so each is named, in the present tense (how each cell was measured, and which were not, is stated under the table): | backend | what it does with the lowered `{ tags: ['a'] }` | |:--|:--| | the SQL family: `driver-sql`, the `driver-turso` / `driver-sqlite-wasm` drivers built on it, and turso's remote transport | **REFUSES** — the bare `{ field: value }` loop asserts the comparand against its own scalar-operator set, an array is none of the six accepted comparand types (`a string, number, bigint, boolean, null or Date`), and it comes back as the withheld `INVALID_FILTER` / 400 envelope | | `driver-memory` | **REFUSES** — the same shape in the same envelope | -| `@objectstack/formula` | **EXCLUDES** — `matchesFilterCondition` answers `false` for every row, a row whose stored value IS `['a']` included | | `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | -How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, the formula matcher, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. Run in this change's review: `driver-sql` on a live PostgreSQL 16 (refused before any SQL statement was emitted), `driver-sqlite-wasm`, and turso's remote transport over the repository's libsql stub. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. +How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. Run in this change's review: `driver-sql` on a live PostgreSQL 16 (refused before any SQL statement was emitted), `driver-sqlite-wasm`, and turso's remote transport over the repository's libsql stub. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. -**None of the four reads the array as the scalar the operator declares.** Today three return no rows for it — the SQL family and `driver-memory` with a 400, the formula matcher with a silent exclusion that, unlike a 400, reads as a true statement about the data. `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it too reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. +**None reads the array as the scalar the operator declares.** `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. Two carve-outs are kept and pinned, because a narrowing that runs past the query path is the mirror-image defect: an **omitted** value still parses (`value` is optional), and the four **valueless** operators (`is_empty` / `is_not_empty` / `is_null` / `is_not_null`) still accept anything in the value position — they take their direction from the operator NAME, the lowering discards the value, and the ObjectUI client deliberately sends a truthy placeholder there. @@ -31,7 +30,7 @@ Two carve-outs are kept and pinned, because a narrowing that runs past the query `@objectstack/spec/data`'s `FILTER_TEXT_CASES` declares two REJECTION rows for the case-insensitive contains operator — an **empty** comparand and a **non-string** one, each `code: 'INVALID_FILTER'`. All five driver packages run both rows in their own suites, and the drivers re-run for this change — `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter` — each refuse both comparands with `INVALID_FILTER` / 400; the formula matcher does not refuse them, it answers `false` for every row. Nothing applied them at parse, on either vocabulary, so the protocol declared the refusal and then admitted the document that would hit it. Both doors now refuse: the `$` dialect's `FilterConditionSchema` and the view vocabulary's `icontains` arm. -The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. Scope is the one operator the table writes rows for: `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. +The predicate is **derived from the table, not transcribed beside it** — both doors call the published `isRefusedTextComparand` and `textComparandRefusalReason`, so a row added to `FILTER_TEXT_CASES` reaches both doors with no edit at either, and the reason an author reads at authoring time is byte-identical to the one three shipped consumer faces already show at query time. `$contains`, `$startsWith`, `$endsWith`, `$like` and `$ilike` are untouched, because widening by analogy is the table's decision and not a door's. One asymmetry between the two vocabularies, and it is a fact about them rather than an extra rule: a view rule's `value` is optional, so an **absent** comparand is left unjudged there; the `$` dialect has no absent, so an explicit `undefined` in a comparand slot is the refused non-string shape. @@ -57,9 +56,9 @@ The key is described as *"Legacy base-filter fallback, read only when `filter` i | `defaultFilters: { status: 'active' }` | `defaultFilters: [{ field: 'status', operator: 'equals', value: 'active' }]` — better, move it to `filter` and delete the key | | `defaultFilters: [['owner_id', '=', '{current_user_id}']]` | `defaultFilters: [{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` | -Each refusal carries its own prescription at the key that raised it, so `os validate` / `os lint` make the sweep mechanical rather than by eye. What the rewrite changes on the page differs by row, so read them apart: +What the rewrite changes on the page differs by row, so read them apart: -- **The scalar-operator array.** At this release the SQL family and `driver-memory` answer it with a 400 and the formula matcher excludes every row; `driver-mongodb` returns the rows whose stored array equals the value or holds it as an element (a live `mongod` is NOT MEASURED). How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. +- **The scalar-operator array.** How releases before this one answered it is set out in § 1 and is only partly measured. Re-check what each view is supposed to show rather than assuming the old result set was correct. - **The two `icontains` comparands.** At this release each of the five driver packages answers both with a 400 and the formula matcher excludes every row; how earlier releases answered them is NOT MEASURED. - **`defaultFilters`.** The record form and the AST tuple array were applied as declared in the pinned objectui, so for them the rewrite is a spelling change. A bare string or a number was dropped, so that grid has been listing its rows unfiltered — decide which rows it should show before writing the rule. Beside a non-empty `filter`, deleting `defaultFilters` is the whole migration; beside `filter: []` the grid reads `defaultFilters`, so move its rules onto `filter` rather than deleting them. diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts index 09b4beb362e..30bc1af1278 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-icontains-comparand-refused-at-parse.ts @@ -40,8 +40,8 @@ export const entry: SemanticMigration = { + 'the published predicate isRefusedTextComparand and the published reason text ' + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' - + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' - + '$endsWith, $like and $ilike have no such row and keep the answer they give today, ' + + '$contains, $startsWith, ' + + '$endsWith, $like and $ilike keep the answer they give today, ' + 'because widening by analogy is the table\'s decision and not a door\'s. ' + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' @@ -64,7 +64,5 @@ export const entry: SemanticMigration = { + 'formula matcher answers false for every row. How earlier releases answered them was ' + 'NOT measured, so re-check ' + 'what the view is supposed to show rather than assuming the old result set was correct. ' - + 'Both refusals now arrive at the authoring path with the same reason text ' - + 'the runtime gives, so the message an author reads is the same message wherever they ' - + 'hit it.', + + 'Both refusals now arrive at the authoring path.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 6d672680601..ef1c5dba8ee 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -15,9 +15,7 @@ export const entry: SemanticMigration = { 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' - + 'every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, ' - + 'Page.filterBy, a related-list filter, a lookup picker filter, and the filter and ' - + 'defaultFilters keys of the object-* page blocks', + + 'every carrier of ViewFilterRuleSchema', replacement: 'one scalar — a string, number, boolean or null. A rule written ' + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' @@ -38,8 +36,8 @@ export const entry: SemanticMigration = { + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. The backends a lowered view rule reaches are four, and ' - + 'at this release they do not agree, so each is named rather than generalised. The SQL ' + + 'which every backend answers」. The backends a lowered view rule reaches ' + + 'at this release do not agree, so each is named rather than generalised. The SQL ' + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + 'of the six accepted comparand types the platform declares in ' @@ -47,8 +45,8 @@ export const entry: SemanticMigration = { + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' - + 'the implicit-equality position). The formula matcher EXCLUDES every row, a row whose ' - + 'stored value IS the array included. driver-mongodb ANSWERS: its translateFilter passes ' + + 'the implicit-equality position). ' + + 'driver-mongodb ANSWERS: its translateFilter passes ' + 'the array through unchanged and the engine\'s shared comparand doors ' + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' @@ -56,25 +54,21 @@ export const entry: SemanticMigration = { + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' - + 'the formula matcher, driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'driver-mongodb\'s translateFilter and mingo were each run on the ' + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' - + 'MySQL and a live Turso server were NOT measured. None of the four reads the array as ' + + 'MySQL and a live Turso server were NOT measured. None reads the array as ' + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' + 'accept set past the query path: at this release a stored view carrying the shape gets ' - + 'a 400 from the SQL family and driver-memory and no rows from the formula matcher, and ' - + 'on MongoDB it selects by a predicate the rule never wrote. The narrowing refuses what ' - + 'the SQL family and driver-memory already refuse and goes no further, because a schema ' - + 'that refuses more than the query path does would reject stored metadata that runs ' - + 'correctly. ' + + 'a 400 from the SQL family and driver-memory, and ' + + 'on MongoDB it selects by a predicate the rule never wrote. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + 'two on equals has no honest single value, and picking the first is a different ' + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' - + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' - + 'storing a filter that the SQL family and driver-memory answer with a 400. ADR-0049 / ' + + 'loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ' + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' @@ -82,18 +76,7 @@ export const entry: SemanticMigration = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite, as each backend a lowered view rule reaches answers ' - + 'such a rule at this release: the SQL family and driver-memory answer it with 400 ' - + 'INVALID_FILTER, and the formula matcher excludes every row. driver-mongodb returns ' - + 'rows, but not the ones the operator declares: the array reaches the server unchanged, ' - + 'where MongoDB\'s equality rule for an array operand selects a row whose stored array ' - + 'equals the value or holds it as one of its elements, and not a row storing the scalar ' - + '— read at the driver\'s compile face and through mingo 7.2.4; a live mongod instance ' - + 'was NOT measured. If you are upgrading from 17.x, your release may have answered ' - + 'differently: driver-memory refuses the shape from 17.4.0, and its published 17.3.0 ' - + 'returned the row stored as the array (run in this change\'s review; which other rows ' - + 'it selected, nested arrays included, was NOT measured); whether any earlier SQL-family ' - + 'release answered the shape was NOT measured. Either way, re-check what the view is ' + + 'Either way, re-check what the view is ' + 'supposed to show rather than assuming the old result set was correct. A one-element ' + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b09f395212d..8f20de156a2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8584,8 +8584,8 @@ const step18: MigrationStep = { + 'the published predicate isRefusedTextComparand and the published reason text ' + 'textComparandRefusalReason, the pair lifted into this package at #18113 for exactly ' + 'this reason, so a row added to the table reaches both doors without an edit at either. ' - + 'Scope is the one operator the table writes rows for: $contains, $startsWith, ' - + '$endsWith, $like and $ilike have no such row and keep the answer they give today, ' + + '$contains, $startsWith, ' + + '$endsWith, $like and $ilike keep the answer they give today, ' + 'because widening by analogy is the table\'s decision and not a door\'s. ' + 'The two vocabularies differ on one point and it is a fact about them rather than an ' + 'extra rule: a view rule\'s value key is OPTIONAL, so an absent comparand is left ' @@ -8608,9 +8608,7 @@ const step18: MigrationStep = { + 'formula matcher answers false for every row. How earlier releases answered them was ' + 'NOT measured, so re-check ' + 'what the view is supposed to show rather than assuming the old result set was correct. ' - + 'Both refusals now arrive at the authoring path with the same reason text ' - + 'the runtime gives, so the message an author reads is the same message wherever they ' - + 'hit it.', + + 'Both refusals now arrive at the authoring path.', }, { id: 'filter-preset-ordering-comparand-refused', @@ -13079,9 +13077,7 @@ const step18: MigrationStep = { 'ui.ViewFilterRule value on a SCALAR operator — an ARRAY where the operator takes one ' + 'value (equals, not_equals, contains, not_contains, icontains, starts_with, ends_with, ' + 'greater_than, less_than, greater_than_or_equal, less_than_or_equal, before, after), on ' - + 'every carrier of ViewFilterRuleSchema: ListView.filter, a list view tab filter, ' - + 'Page.filterBy, a related-list filter, a lookup picker filter, and the filter and ' - + 'defaultFilters keys of the object-* page blocks', + + 'every carrier of ViewFilterRuleSchema', replacement: 'one scalar — a string, number, boolean or null. A rule written ' + 'value: ["won"] on equals becomes value: "won"; a rule that really did mean membership ' @@ -13102,8 +13098,8 @@ const step18: MigrationStep = { + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. The backends a lowered view rule reaches are four, and ' - + 'at this release they do not agree, so each is named rather than generalised. The SQL ' + + 'which every backend answers」. The backends a lowered view rule reaches ' + + 'at this release do not agree, so each is named rather than generalised. The SQL ' + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' + 'of the six accepted comparand types the platform declares in ' @@ -13111,8 +13107,8 @@ const step18: MigrationStep = { + 'INVALID_FILTER / 400 envelope — and with it the driver-turso and driver-sqlite-wasm ' + 'drivers built on driver-sql, and turso\'s remote transport. driver-memory REFUSES the ' + 'same shape in the same envelope (its assertFilterConditionShape throws on an array in ' - + 'the implicit-equality position). The formula matcher EXCLUDES every row, a row whose ' - + 'stored value IS the array included. driver-mongodb ANSWERS: its translateFilter passes ' + + 'the implicit-equality position). ' + + 'driver-mongodb ANSWERS: its translateFilter passes ' + 'the array through unchanged and the engine\'s shared comparand doors ' + '(normalizeFilterComparandTypes, assertListComparandShapes) both pass the shape, so the ' + 'server applies MongoDB\'s equality rule for an array operand — a row matches when its ' @@ -13120,25 +13116,21 @@ const step18: MigrationStep = { + 'storing the scalar does not match. That MongoDB reading is taken at the driver\'s ' + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' - + 'the formula matcher, driver-mongodb\'s translateFilter and mingo were each run on the ' + + 'driver-mongodb\'s translateFilter and mingo were each run on the ' + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' - + 'MySQL and a live Turso server were NOT measured. None of the four reads the array as ' + + 'MySQL and a live Turso server were NOT measured. None reads the array as ' + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' + 'accept set past the query path: at this release a stored view carrying the shape gets ' - + 'a 400 from the SQL family and driver-memory and no rows from the formula matcher, and ' - + 'on MongoDB it selects by a predicate the rule never wrote. The narrowing refuses what ' - + 'the SQL family and driver-memory already refuse and goes no further, because a schema ' - + 'that refuses more than the query path does would reject stored metadata that runs ' - + 'correctly. ' + + 'a 400 from the SQL family and driver-memory, and ' + + 'on MongoDB it selects by a predicate the rule never wrote. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' + 'two on equals has no honest single value, and picking the first is a different ' + 'predicate. The read path does not re-validate stored rows, so a stored view keeps ' - + 'loading; what changes is that RE-SAVING it is refused at the value path, instead of ' - + 'storing a filter that the SQL family and driver-memory answer with a 400. ADR-0049 / ' + + 'loading; what changes is that RE-SAVING it is refused at the value path. ADR-0049 / ' + 'ADR-0087 / ADR-0112.', acceptanceCriteria: 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' @@ -13146,18 +13138,7 @@ const step18: MigrationStep = { + 'then decide per rule which of the two things it meant: one value, or membership. ' + 'os validate and os lint report each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' - + 'Worth knowing before you rewrite, as each backend a lowered view rule reaches answers ' - + 'such a rule at this release: the SQL family and driver-memory answer it with 400 ' - + 'INVALID_FILTER, and the formula matcher excludes every row. driver-mongodb returns ' - + 'rows, but not the ones the operator declares: the array reaches the server unchanged, ' - + 'where MongoDB\'s equality rule for an array operand selects a row whose stored array ' - + 'equals the value or holds it as one of its elements, and not a row storing the scalar ' - + '— read at the driver\'s compile face and through mingo 7.2.4; a live mongod instance ' - + 'was NOT measured. If you are upgrading from 17.x, your release may have answered ' - + 'differently: driver-memory refuses the shape from 17.4.0, and its published 17.3.0 ' - + 'returned the row stored as the array (run in this change\'s review; which other rows ' - + 'it selected, nested arrays included, was NOT measured); whether any earlier SQL-family ' - + 'release answered the shape was NOT measured. Either way, re-check what the view is ' + + 'Either way, re-check what the view is ' + 'supposed to show rather than assuming the old result set was correct. A one-element ' + 'array is the case to read closest: its two corrected spellings — value: "won" on ' + 'equals, and operator: "in" with value: ["won"] — select the same rows, so the result ' diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index b7b94eac7dd..e2671e71cf3 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2737,8 +2737,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * * ⛔ **Narrowed, NOT retired.** Refusing the key outright is the other arm this * could have taken and it is a REMOVAL of an accepted shape, which needs its - * own ruling; this change only pulls the ACCEPT SET back to the one the - * consumer already honours. The deprecation stated in the description stands + * own ruling. The deprecation stated in the description stands * exactly where it stood — prefer `filter` — and is unchanged by this. * * The `{ error }` map is `filter`'s, deliberately: an author who wrote the diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index 33820bb985b..78cb08910ab 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -17,19 +17,7 @@ * reasoning but a correction of one of its readings: an array on a scalar * operator was recorded here as accepted because it 「lowers to a bare * deep-equality comparand, which every backend answers」, and re-measurement - * found the opposite. The four backends a lowered view rule reaches do not - * agree at this change: the SQL family (`driver-sql`, the `driver-turso` / - * `driver-sqlite-wasm` drivers built on it, and turso's remote transport) and - * `driver-memory` REFUSE the comparand with `INVALID_FILTER`; - * `@objectstack/formula`'s matcher EXCLUDES every row; and `driver-mongodb` - * ANSWERS — it passes the array through to the server, where MongoDB's - * equality rule for an array operand selects a row whose stored array equals - * `['a']` or holds `['a']` as an element, and not a row storing the scalar - * `'a'` (read at the driver's compile face, at the engine's shared comparand - * doors and through mingo 7.2.4; a live `mongod` cell is NOT MEASURED). None - * of the four reads the array as the scalar the operator declares, so a view - * carrying it gets a 400 or no rows on three of them and, on MongoDB, rows - * chosen by a predicate the rule never wrote. The pins below carry both + * found the opposite. The pins below carry both * directions of that arm, and the carve-outs * (an absent value, the four valueless operators) keep their * own pins, because the #5685 side of this file is what stops a narrowing from @@ -186,10 +174,10 @@ describe('#6227 — what stays accepted (the #5685 side: never stricter than the ['not_in + empty array', { field: 'f', operator: 'not_in', value: [] }], ['between + pair', { field: 'f', operator: 'between', value: [1, 2] }], ['between + ISO date pair', { field: 'd', operator: 'between', value: ['2024-01-01', '2024-12-31'] }], - // A string operator carrying a number: none of the four backends a lowered + // A string operator carrying a number: none of the backends a lowered // view rule reaches refuses it (`driver-sql` on SQLite and `driver-memory` - // answer it, `driver-mongodb` compiles it, the formula matcher excludes - // every row). (`icontains` is the one exception and it is the TABLE's row, + // answer it, `driver-mongodb` compiles it). + // (`icontains` is the one exception and it is the TABLE's row, // not an analogy — see the comparand pins below.) ['contains + number', { field: 'f', operator: 'contains', value: 5 }], ['starts_with + number', { field: 'f', operator: 'starts_with', value: 5 }], diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0d1a616b1cb..e84baf93e12 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -573,11 +573,8 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * The first two checks below are `assertListComparandShapes`' constraints, one * for one: `$in`/`$nin` must be an array, `$between` must be a 2-array. The * third — a SCALAR operator handed an array — is `driver-sql`'s - * `assertCompilableComparand` scalar arm, and it is here for the same reason the - * other two are: the query path refuses it (on the SQL family and - * `driver-memory`; all four backends are named below), so refusing it at - * authoring time moves the refusal to the moment the author can still act on - * it. Nothing beyond those is judged, deliberately — #5685 already ruled on the + * `assertCompilableComparand` scalar arm. + * Nothing beyond those is judged, deliberately — #5685 already ruled on the * opposite error, where `FieldOperatorsSchema` declared `$gt` as * `number | Date | FieldReference` while every first-party producer put an ISO * STRING there; the schema was ruled the wrong side and widened to match the @@ -588,7 +585,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * ⚠️ **The scalar arm REVERSES a reading recorded here at #6227**, which said * `equals: ['a','b']` "lowers to a bare `{ field: value }` deep-equality * comparand, which every backend answers". **The backends a lowered view rule - * reaches are four, not three, and at this change they do not agree** — so + * reaches at this change do not agree** — so * each is named rather than generalised, in the present tense: * * - **The SQL family REFUSES** — `driver-sql`, the `driver-turso` / @@ -605,10 +602,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * `@objectstack/driver-memory@17.4.0`; published 17.3.0 returned the row * stored as `['a']` (run in this change's review; which other rows it * selected is NOT MEASURED). - * - **`@objectstack/formula` EXCLUDES** — `matchesFilterCondition` answers - * `false` for every row, a row whose stored value IS `['a']` included. - * - **`driver-mongodb` ANSWERS** — the fourth backend, and the one the earlier - * wording left out. `translateFilter({ tags: ['a'] })` emits + * - **`driver-mongodb` ANSWERS** — `translateFilter({ tags: ['a'] })` emits * `{"tags":["a"]}` unchanged (the array falls to the implicit-equality arm), * and the engine's shared comparand doors — `normalizeFilterComparandTypes` * and `assertListComparandShapes` — both pass the shape, so the server @@ -620,7 +614,7 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * face, at those engine doors and through `mingo`; a live `mongod` cell is * NOT MEASURED. * - * Method: `driver-sql` on SQLite, `driver-memory`, the formula matcher, + * Method: `driver-sql` on SQLite, `driver-memory`, * `driver-mongodb`'s `translateFilter` and `mingo` were each run on the * lowered `{ tags: ['a'] }` beside a scalar and an `$in` control; this * change's review also ran `driver-sql` on a live PostgreSQL 16 (refused with @@ -629,8 +623,8 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * a live `mongod` are NOT MEASURED, and so is whether any SQL-family release * before this change answered the shape. * - * So **none of the four reads the array as the SCALAR the operator - * declares**: three refuse or exclude it outright, and `driver-mongodb` returns + * So **none reads the array as the SCALAR the operator + * declares**: `driver-mongodb` returns * rows for a different predicate — array equality, and only on an array-valued * field (live `mongod` NOT MEASURED). The ORIGINAL reading was therefore the one that widened the accept * set past the query path; this arm pulls it back to what `value`'s own @@ -647,9 +641,9 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * as many words. Arity is not this check's business for membership; only "is it * a list at all". * - **A string operator carrying a number** (`contains: 5`). Lowers to - * `$contains: 5`; none of the four backends above refuses it (`driver-sql` + * `$contains: 5`; none of the backends above refuses it (`driver-sql` * on SQLite and `driver-memory` answer it, `driver-mongodb` compiles it to a - * `$regex`, the formula matcher excludes every row). `icontains` is the ONE + * `$regex`). `icontains` is the ONE * exception and it is not an analogy — `FILTER_TEXT_CASES` declares that * comparand refused as data, and {@link checkViewFilterRuleTextComparand} * below answers those rows and only those rows. @@ -767,8 +761,7 @@ function checkViewFilterRuleValueShape( } /** - * [#19514] `icontains` takes the comparand `FILTER_TEXT_CASES` declares, - * on the one operator that table writes a row for. + * [#19514] `icontains` takes the comparand `FILTER_TEXT_CASES` declares. * * ## The half this answers, and the half it leaves alone * @@ -795,8 +788,8 @@ function checkViewFilterRuleValueShape( * 2. **ARRAYS** are {@link checkViewFilterRuleValueShape}'s business. One defect, * one issue: an author who wrote `icontains: ['a']` is told about the SHAPE, * which is what they have to fix first. - * 3. **THE SIBLING OPERATORS.** `contains` / `starts_with` / `ends_with` have no - * row in the table and keep the answer they give today. Widening by + * 3. **THE SIBLING OPERATORS.** `contains` / `starts_with` / `ends_with` + * keep the answer they give today. Widening by * analogy is the table's decision to make, never this door's. * * ## The `$` twin is named here, not in the contract half From 07d787ce962274ab47901cd9b7b48fe0a93c7eb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 06:20:07 +0000 Subject: [PATCH 15/17] docs(spec): delete the scalar reason's class-wide stored-view sentence (#19514) The seat's Q1 ruling (B) on round 5: the scalar entry covers all 13 scalar operators, so "None reads the array as the SCALAR the operator declares, so the earlier reading ... on MongoDB it selects by a predicate the rule never wrote." reads class-wide, where it is false (driver-memory answers contains ['a'] with rows). The whole sentence is deleted from the entry's reason; nothing else in the reason moves. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18.view-filter-rule-scalar-operator-array-refused.ts | 6 +----- packages/spec/src/migrations/registry.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index ef1c5dba8ee..9716ab102d3 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -58,11 +58,7 @@ export const entry: SemanticMigration = { + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' - + 'MySQL and a live Turso server were NOT measured. None reads the array as ' - + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' - + 'accept set past the query path: at this release a stored view carrying the shape gets ' - + 'a 400 from the SQL family and driver-memory, and ' - + 'on MongoDB it selects by a predicate the rule never wrote. ' + + 'MySQL and a live Turso server were NOT measured. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8f20de156a2..6f129292105 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13120,11 +13120,7 @@ const step18: MigrationStep = { + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' - + 'MySQL and a live Turso server were NOT measured. None reads the array as ' - + 'the SCALAR the operator declares, so the earlier reading was the one that widened the ' - + 'accept set past the query path: at this release a stored view carrying the shape gets ' - + 'a 400 from the SQL family and driver-memory, and ' - + 'on MongoDB it selects by a predicate the rule never wrote. ' + + 'MySQL and a live Turso server were NOT measured. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' + 'conversions only. Coercing at load would be the platform guessing intent — an array of ' From c3cea5afa6ffdff399c433acd89d323fce398d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:32:06 +0000 Subject: [PATCH 16/17] docs(spec): drop "and os lint" and the voided-record run attributions (#19514) Round 6, two deletions ordered by the seat: - B1: the scalar entry's acceptanceCriteria said "os validate and os lint report each one by path"; os lint does not report this rule for a view's filter. "and os lint" is deleted and the verb agrees with its single subject ("reports"), the one non-deletion the order allows. - N1: the attributions saying this change's review also ran driver-sql on a live PostgreSQL 16, driver-sqlite-wasm and turso's remote transport rest on records the seat voided. They are deleted from the scalar entry's reason, the changeset's "How each cell was measured" paragraph and the view.zod.ts docblock. The driver-memory 17.3.0 attributions stay. registry.ts regenerated by gen:migration-registry. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...4-view-filter-rule-scalar-arm-and-icontains-comparand.md | 2 +- .../18.view-filter-rule-scalar-operator-array-refused.ts | 6 ++---- packages/spec/src/migrations/registry.ts | 6 ++---- packages/spec/src/ui/view.zod.ts | 6 ++---- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md index d61f031d406..c4a3316ccca 100644 --- a/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md +++ b/.changeset/19514-view-filter-rule-scalar-arm-and-icontains-comparand.md @@ -20,7 +20,7 @@ Direction set by objectui#9050's ruling C′, quoted untranslated: 「the differ | `driver-memory` | **REFUSES** — the same shape in the same envelope | | `driver-mongodb` | **ANSWERS** — `translateFilter` passes the array through unchanged and the engine's shared comparand doors pass the shape, so the server applies MongoDB's equality rule for an array operand: a row matches when its stored array **equals** `['a']` **or holds `['a']` as an element**, and a row storing the scalar `'a'` does not (mingo 7.2.4, over `['a']`, `'a'`, `['a', 'b']`, `['b', 'a']`, `[['a'], 'x']`, `[['a']]` and `'b'`, selects `['a']`, `[['a'], 'x']` and `[['a']]`); a live `mongod` is NOT MEASURED | -How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. Run in this change's review: `driver-sql` on a live PostgreSQL 16 (refused before any SQL statement was emitted), `driver-sqlite-wasm`, and turso's remote transport over the repository's libsql stub. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. +How each cell was measured. Run for this change on the lowered `{ tags: ['a'] }`, each beside a scalar and an `$in` control: `driver-sql` on SQLite, `driver-memory`, `driver-mongodb`'s `translateFilter`, and mingo 7.2.4 for MongoDB's rule. ⚠️ NOT MEASURED: MySQL, a live Turso server, and a live `mongod` — the MongoDB row is read at the driver's compile face, at the engine's shared comparand doors and through mingo. **None reads the array as the scalar the operator declares.** `driver-mongodb` returns rows — but for a different predicate, and only on an array-valued field, so it reads as a true statement about data the rule never asked for (a live `mongod` is NOT MEASURED). Earlier releases are a separate question, and only partly measured: `driver-memory` refuses the shape from 17.4.0, while its published 17.3.0 returned the row stored as `['a']` (run in this change's review; which other rows it selected, nested arrays included, is NOT MEASURED); whether any earlier SQL-family release answered the shape is NOT MEASURED. diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index 9716ab102d3..f8abb593483 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -55,9 +55,7 @@ export const entry: SemanticMigration = { + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + 'driver-mongodb\'s translateFilter and mingo were each run on the ' - + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' - + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' - + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' + + 'lowered node beside a scalar and an $in control; ' + 'MySQL and a live Turso server were NOT measured. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' @@ -70,7 +68,7 @@ export const entry: SemanticMigration = { 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + 'then decide per rule which of the two things it meant: one value, or membership. ' - + 'os validate and os lint report each one by path with the operator, the received shape ' + + 'os validate reports each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + 'Either way, re-check what the view is ' + 'supposed to show rather than assuming the old result set was correct. A one-element ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6f129292105..3c63eee96ce 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13117,9 +13117,7 @@ const step18: MigrationStep = { + 'compile face, at those engine doors and through mingo 7.2.4, which applies that rule; ' + 'a live mongod instance was NOT measured. Method: driver-sql on SQLite, driver-memory, ' + 'driver-mongodb\'s translateFilter and mingo were each run on the ' - + 'lowered node beside a scalar and an $in control, and this change\'s review also ran ' - + 'driver-sql on a live PostgreSQL 16 (refused before any SQL statement was emitted), ' - + 'driver-sqlite-wasm, and turso\'s remote transport over the repository\'s libsql stub; ' + + 'lowered node beside a scalar and an $in control; ' + 'MySQL and a live Turso server were NOT measured. ' + 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion: a ' + 'SemanticMigration converts nothing by its own type, and the stored-row pass replays D2 ' @@ -13132,7 +13130,7 @@ const step18: MigrationStep = { 'Grep your authored views, pages and object-* blocks for a filter rule whose operator is ' + 'none of in / not_in / between / the four unary operators and whose value is an array, ' + 'then decide per rule which of the two things it meant: one value, or membership. ' - + 'os validate and os lint report each one by path with the operator, the received shape ' + + 'os validate reports each one by path with the operator, the received shape ' + 'and both corrected spellings, so the sweep is mechanical rather than by eye. ' + 'Either way, re-check what the view is ' + 'supposed to show rather than assuming the old result set was correct. A one-element ' diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index e84baf93e12..1339953de4d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -616,10 +616,8 @@ const VIEW_FILTER_TEXT_COMPARAND_OPERATOR = 'icontains' satisfies ViewFilterOper * * Method: `driver-sql` on SQLite, `driver-memory`, * `driver-mongodb`'s `translateFilter` and `mingo` were each run on the - * lowered `{ tags: ['a'] }` beside a scalar and an `$in` control; this - * change's review also ran `driver-sql` on a live PostgreSQL 16 (refused with - * zero SQL statements emitted), `driver-sqlite-wasm`, and turso's remote - * transport over the repository's libsql stub. MySQL, a live Turso server and + * lowered `{ tags: ['a'] }` beside a scalar and an `$in` control; + * MySQL, a live Turso server and * a live `mongod` are NOT MEASURED, and so is whether any SQL-family release * before this change answered the shape. * From aba7c1004694838ed0ddce2c45828df8f001c539 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 08:40:56 +0000 Subject: [PATCH 17/17] docs(spec): quote the sibling entry verbatim in the scalar reason (#19514) Round 7, B1: the scalar entry's reason quoted the sibling entry view-filter-rule-value-shaped-by-operator as "lowers to a bare deep-equality comparand, which every backend answers"; the sibling says "(it lowers to a deep-equality comparand)". "bare " and ", which every backend answers" are deleted from the quotation so it matches the sibling word for word, and the same two deletions are made in the test docblock. registry.ts regenerated by gen:migration-registry. Every other quotation this PR adds that is attributed to a named source was checked against that source, seam-joined; none carries a word its source lacks, so nothing else moves. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../18.view-filter-rule-scalar-operator-array-refused.ts | 4 ++-- packages/spec/src/migrations/registry.ts | 4 ++-- packages/spec/src/ui/view-filter-rule-value-shape.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts index f8abb593483..e90a8b819e8 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-filter-rule-scalar-operator-array-refused.ts @@ -35,8 +35,8 @@ export const entry: SemanticMigration = { + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' - + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. The backends a lowered view rule reaches ' + + 'array as deliberately accepted because it 「lowers to a deep-equality comparand' + + '」. The backends a lowered view rule reaches ' + 'at this release do not agree, so each is named rather than generalised. The SQL ' + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3c63eee96ce..78c7a1f75fe 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13097,8 +13097,8 @@ const step18: MigrationStep = { + 'not judged. ' + '⚠️ This REVERSES a reading recorded in the sibling entry ' + 'view-filter-rule-value-shaped-by-operator, which listed a scalar operator carrying an ' - + 'array as deliberately accepted because it 「lowers to a bare deep-equality comparand, ' - + 'which every backend answers」. The backends a lowered view rule reaches ' + + 'array as deliberately accepted because it 「lowers to a deep-equality comparand' + + '」. The backends a lowered view rule reaches ' + 'at this release do not agree, so each is named rather than generalised. The SQL ' + 'family REFUSES: the lowered node reaches driver-sql\'s bare field-value loop, which ' + 'asserts the comparand against its own SCALAR_COMPARAND_OPERATORS set; an array is none ' diff --git a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts index 78cb08910ab..fb6575afc23 100644 --- a/packages/spec/src/ui/view-filter-rule-value-shape.test.ts +++ b/packages/spec/src/ui/view-filter-rule-value-shape.test.ts @@ -15,8 +15,8 @@ * [#19514] The SCALAR arm joined them, and it moved three pins in this file from * the accepted side to the refused side. It is not an extension of #6227's * reasoning but a correction of one of its readings: an array on a scalar - * operator was recorded here as accepted because it 「lowers to a bare - * deep-equality comparand, which every backend answers」, and re-measurement + * operator was recorded here as accepted because it 「lowers to a + * deep-equality comparand」, and re-measurement * found the opposite. The pins below carry both * directions of that arm, and the carve-outs * (an absent value, the four valueless operators) keep their