From dfa424f65a8ff3d6f4d335c9557b7d2389b62f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:11:53 +0000 Subject: [PATCH 1/8] refactor(spec): the analytics carriers' filter slot moves to its own module A verbatim move of `refuseNestedRelationComparands` and `analyticsCarrierFilter` (with their two predicates) out of `ui/dataset.zod.ts` into `ui/analytics-carrier-filter.ts`, a non-barrel module, so a second analytics carrier can share the one declaration. `DatasetSchema` and `DatasetMeasureSchema` call it exactly as before. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../src/data/filter-save-door-refusals.ts | 2 +- packages/spec/src/data/filter.zod.ts | 2 +- .../spec/src/ui/analytics-carrier-filter.ts | 176 ++++++++++++++++++ packages/spec/src/ui/dataset.zod.ts | 168 +---------------- 4 files changed, 181 insertions(+), 167 deletions(-) create mode 100644 packages/spec/src/ui/analytics-carrier-filter.ts diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 2160a2f3321..cdf29b5756d 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -10,7 +10,7 @@ * `$and` / `$or` / `$not` member — the shared comparand face's reach, which * every schema carrying a `FilterCondition` gets; * - the analytics carriers' nested-relation walk - * (`refuseNestedRelationComparands`, `../ui/dataset.zod.ts`), over the + * (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), over the * entries INSIDE a nested-relation condition, which the analytics `where` * door flattens to dotted members and judges like any other entry. * diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index c0f484b4737..959113afc2b 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1742,7 +1742,7 @@ function isPlainFilterNode(value: unknown): value is Record { * with no `$` key. The drivers' flag checks stop at the same place. The * analytics `where` door does descend a nested relation (it flattens one to a * dotted member), so those positions belong to the analytics carriers' own - * walk (`refuseNestedRelationComparands`, `../ui/dataset.zod.ts`), which + * walk (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), which * asks the same function — never to this shared walk, which every other * carrier reads. * diff --git a/packages/spec/src/ui/analytics-carrier-filter.ts b/packages/spec/src/ui/analytics-carrier-filter.ts new file mode 100644 index 00000000000..08a98523816 --- /dev/null +++ b/packages/spec/src/ui/analytics-carrier-filter.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The filter slot of every ANALYTICS carrier — a filter that is charted through + * the analytics `where` door — declared once: `FilterConditionSchema` plus the + * nested-relation walk that door's reach needs (see {@link analyticsCarrierFilter}). + * + * A module of its own, and outside the `ui` barrel, so the carriers in + * `./dataset.zod.ts` and `./dashboard.zod.ts` share one declaration without it + * becoming published API. + */ + +import type { z } from 'zod'; +import { FieldOperatorsSchema, FilterConditionSchema } from '../data/filter.zod'; +import { reportQueryFaceRefusals } from '../data/filter-save-door-refusals'; + +// ── [#20080] The analytics door's reach, on the two analytics carriers ────── + +/** + * A node the analytics `where` door walks: a plain object, not `null`, not an + * array and not a `Date` — that door's own `isFilterObject` + * (`service-analytics` `strategies/filter-normalizer.ts`), mirrored here. + */ +function isAnalyticsFilterObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); +} + +/** + * A NESTED-RELATION field spec, as the analytics door decides it + * (`isNestedRelationSpec` in the same file): a plain object — prototype + * `Object.prototype` or `null`, so a `Map` or a class instance stays a + * comparand — carrying no `$` key. + */ +function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record { + if (!isAnalyticsFilterObject(spec)) return false; + const proto = Object.getPrototypeOf(spec); + if (proto !== Object.prototype && proto !== null) return false; + return !Object.keys(spec).some((key) => key.startsWith('$')); +} + +/** + * [#20080] Refuse, when a dataset or measure filter is SAVED, the list the + * analytics `where` door refuses when that filter is CHARTED: an ARRAY in the + * EQUALITY slot of a field inside a NESTED-RELATION condition — + * `{ account: { region: ['a'] } }` and `{ account: { region: { $eq: ['a'] } } }`. + * [#20116] And, since #20116, every other comparand slot that door refuses + * there: the whole of the query faces' verdict, asked of the one function + * `FilterConditionSchema`'s own walk asks (`reportQueryFaceRefusals`, + * `../data/filter-save-door-refusals.ts`). See the last section. + * + * ## Why this is a carrier refinement and not the shared schema's + * + * `FilterConditionSchema` refuses an equality-slot list on the field entries + * of a condition and of every `$and` / `$or` / `$not` member, and deliberately + * NOT inside a field spec with no `$` key: that is the shared comparand face's + * reach, and the engine reads such a spec as a deep-equality comparand (ruling + * A on #19889, record 5805248669). That ruling stands, and this function does + * not touch the shared schema. + * + * The analytics door reads the same spec differently. Its `fieldLeaves` + * flattens a nested relation to the dotted member `account.region`, so the + * entries inside are comparisons in their own right, and + * `assertNoListInEqualitySlot` hands each equality-slot list among them to the + * shared face, which refuses it with `INVALID_FILTER` / 400. A dataset `filter` + * and a measure `filter` are charted through that door on every path: the + * dataset executor hands them to the analytics query, and the native-SQL and + * ObjectQL strategies and the draft preview each lower them through it. So + * until this refinement such a dataset saved clean and every chart built on it + * failed, for somebody else, later. Triage routed the fix to these two + * carriers (record 5825670610, remedy A): the refusal moves to save, and no + * filter changes meaning. + * + * ## The reach is the analytics door's, and only the part the schema lacks + * + * The walk is `mapWhereFieldEntries`' traversal: `$and` / `$or` arrays and + * `$not` are descended; every other `$` key at node level is skipped; a + * nested-relation spec is descended, at any depth; every other field entry is + * judged as that door judges it — each slot handed to the query faces (see the + * last section), of which the equality-slot list is one arm. No depth bound: + * that door has none. + * + * Only an entry INSIDE a nested relation is reported. Every other entry the + * walk visits is a field entry of the condition or of a combinator member, + * which `FilterConditionSchema`'s own refinement already refuses with the same + * words — reporting it here as well would raise the one refusal twice. + * + * ## The words are the face's + * + * `arrayEqualityComparandMessage` is the one builder the shared face and the + * schema door both print from. The analytics door names the field it hands to + * the face — the leaf key (`region`), with the location `at + * where.account.region` appended. This refinement prints the same sentence and + * leaves the location out, as the schema door does, because the issue's own + * `path` carries it (`filter.account.region`, + * `measures.0.filter.account.region.$eq`). + * + * Nothing is stripped: the parse fails, and a filter that is refused is never + * a filter that is dropped. + * + * ## Every slot the door refuses inside a relation (#20116) + * + * The analytics door hands each entry inside a nested relation to the whole + * comparand-shape face and to its `$null` / `$exists` flag check, not only to + * the equality arm — so a `null` ordering comparand, a non-list `$in` / `$nin`, + * a `null` list member, a malformed `$between` or a `null`, blank or + * `{ $field }` endpoint, an array under `$ne` and a non-boolean flag inside a + * relation all failed on chart while this walk saved them. Each such entry is + * now asked of `reportQueryFaceRefusals`, the function `FilterConditionSchema`'s + * walk asks about the entries IT reaches, so one slot is judged one way + * whichever walk finds it, and a rule added there reaches this reach too. This + * walk still decides only WHERE (the analytics door's traversal); the verdict + * and the words are that function's. The equality lists above are two of its + * arms and keep their sentence and their path. The list operators keep their + * lists (`$in: []` / `$nin: []` included), and the null predicate, a + * `{ $field }` reference as the whole comparand and every scalar pass, because + * the face passes them. + */ +function refuseNestedRelationComparands( + node: unknown, + ctx: z.RefinementCtx, + path: (string | number)[] = [], + insideRelation = false, +): void { + if (!isAnalyticsFilterObject(node)) return; + for (const [key, spec] of Object.entries(node)) { + if (key === '$and' || key === '$or') { + if (Array.isArray(spec)) { + spec.forEach((member, index) => + refuseNestedRelationComparands(member, ctx, [...path, key, index], insideRelation)); + } + continue; + } + if (key === '$not') { + refuseNestedRelationComparands(spec, ctx, [...path, key], insideRelation); + continue; + } + if (key.startsWith('$')) continue; + if (isAnalyticsNestedRelationSpec(spec)) { + refuseNestedRelationComparands(spec, ctx, [...path, key], true); + continue; + } + if (!insideRelation) continue; // `FilterConditionSchema` judges this entry itself + // [#20116] Every slot the analytics door hands to the query faces, asked of + // the one function `FilterConditionSchema`'s own walk asks: an implicit + // comparand, or each operator of an operator map. + if (!isAnalyticsFilterObject(spec)) { + reportQueryFaceRefusals(ctx, [...path, key], key, undefined, spec, FieldOperatorsSchema); + continue; + } + for (const [op, comparand] of Object.entries(spec)) { + if (!op.startsWith('$')) continue; + reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema); + } + } +} + +/** + * The optional filter both analytics carriers declare — `DatasetSchema.filter` + * and `DatasetMeasureSchema.filter` — which is `FilterConditionSchema` plus + * {@link refuseNestedRelationComparands}. Every other schema that carries a + * `FilterCondition` keeps the shared schema's reach. + * + * The check sits on the OPTIONAL wrapper, not on `FilterConditionSchema` + * itself: refining the recursive schema would clone it, and the published JSON + * Schema would then inline a second copy of the condition beside the `$ref` it + * carries today. On the wrapper the condition keeps its identity, so the + * published body of `ui/Dataset` and `ui/DatasetMeasure` is unchanged, and the + * new rule is recorded as a dropped refinement at each carrier's `filter` in + * `dropped-refinements.baseline.json` (`z.toJSONSchema()` has no projection for + * it). An absent filter reaches the check as `undefined`, which the walk + * passes. + */ +export function analyticsCarrierFilter() { + return FilterConditionSchema.optional().superRefine((filter, ctx) => + refuseNestedRelationComparands(filter, ctx)); +} diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index a9b21cb11b5..18a47d22dd9 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -5,8 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { FieldOperatorsSchema, FilterConditionSchema } from '../data/filter.zod'; -import { reportQueryFaceRefusals } from '../data/filter-save-door-refusals'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; import { AggregationFunction, DateGranularity } from '../data/query.zod'; @@ -80,167 +79,6 @@ const DATASET_NO_SQL = + '`derived: { op, of: [...] }`, which combines OTHER measures in this dataset by name. ' + 'Joins are compiled from `Dataset.include` — you never write an ON clause.'; -// ── [#20080] The analytics door's reach, on the two analytics carriers ────── - -/** - * A node the analytics `where` door walks: a plain object, not `null`, not an - * array and not a `Date` — that door's own `isFilterObject` - * (`service-analytics` `strategies/filter-normalizer.ts`), mirrored here. - */ -function isAnalyticsFilterObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date); -} - -/** - * A NESTED-RELATION field spec, as the analytics door decides it - * (`isNestedRelationSpec` in the same file): a plain object — prototype - * `Object.prototype` or `null`, so a `Map` or a class instance stays a - * comparand — carrying no `$` key. - */ -function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record { - if (!isAnalyticsFilterObject(spec)) return false; - const proto = Object.getPrototypeOf(spec); - if (proto !== Object.prototype && proto !== null) return false; - return !Object.keys(spec).some((key) => key.startsWith('$')); -} - -/** - * [#20080] Refuse, when a dataset or measure filter is SAVED, the list the - * analytics `where` door refuses when that filter is CHARTED: an ARRAY in the - * EQUALITY slot of a field inside a NESTED-RELATION condition — - * `{ account: { region: ['a'] } }` and `{ account: { region: { $eq: ['a'] } } }`. - * [#20116] And, since #20116, every other comparand slot that door refuses - * there: the whole of the query faces' verdict, asked of the one function - * `FilterConditionSchema`'s own walk asks (`reportQueryFaceRefusals`, - * `../data/filter-save-door-refusals.ts`). See the last section. - * - * ## Why this is a carrier refinement and not the shared schema's - * - * `FilterConditionSchema` refuses an equality-slot list on the field entries - * of a condition and of every `$and` / `$or` / `$not` member, and deliberately - * NOT inside a field spec with no `$` key: that is the shared comparand face's - * reach, and the engine reads such a spec as a deep-equality comparand (ruling - * A on #19889, record 5805248669). That ruling stands, and this function does - * not touch the shared schema. - * - * The analytics door reads the same spec differently. Its `fieldLeaves` - * flattens a nested relation to the dotted member `account.region`, so the - * entries inside are comparisons in their own right, and - * `assertNoListInEqualitySlot` hands each equality-slot list among them to the - * shared face, which refuses it with `INVALID_FILTER` / 400. A dataset `filter` - * and a measure `filter` are charted through that door on every path: the - * dataset executor hands them to the analytics query, and the native-SQL and - * ObjectQL strategies and the draft preview each lower them through it. So - * until this refinement such a dataset saved clean and every chart built on it - * failed, for somebody else, later. Triage routed the fix to these two - * carriers (record 5825670610, remedy A): the refusal moves to save, and no - * filter changes meaning. - * - * ## The reach is the analytics door's, and only the part the schema lacks - * - * The walk is `mapWhereFieldEntries`' traversal: `$and` / `$or` arrays and - * `$not` are descended; every other `$` key at node level is skipped; a - * nested-relation spec is descended, at any depth; every other field entry is - * judged as that door judges it — each slot handed to the query faces (see the - * last section), of which the equality-slot list is one arm. No depth bound: - * that door has none. - * - * Only an entry INSIDE a nested relation is reported. Every other entry the - * walk visits is a field entry of the condition or of a combinator member, - * which `FilterConditionSchema`'s own refinement already refuses with the same - * words — reporting it here as well would raise the one refusal twice. - * - * ## The words are the face's - * - * `arrayEqualityComparandMessage` is the one builder the shared face and the - * schema door both print from. The analytics door names the field it hands to - * the face — the leaf key (`region`), with the location `at - * where.account.region` appended. This refinement prints the same sentence and - * leaves the location out, as the schema door does, because the issue's own - * `path` carries it (`filter.account.region`, - * `measures.0.filter.account.region.$eq`). - * - * Nothing is stripped: the parse fails, and a filter that is refused is never - * a filter that is dropped. - * - * ## Every slot the door refuses inside a relation (#20116) - * - * The analytics door hands each entry inside a nested relation to the whole - * comparand-shape face and to its `$null` / `$exists` flag check, not only to - * the equality arm — so a `null` ordering comparand, a non-list `$in` / `$nin`, - * a `null` list member, a malformed `$between` or a `null`, blank or - * `{ $field }` endpoint, an array under `$ne` and a non-boolean flag inside a - * relation all failed on chart while this walk saved them. Each such entry is - * now asked of `reportQueryFaceRefusals`, the function `FilterConditionSchema`'s - * walk asks about the entries IT reaches, so one slot is judged one way - * whichever walk finds it, and a rule added there reaches this reach too. This - * walk still decides only WHERE (the analytics door's traversal); the verdict - * and the words are that function's. The equality lists above are two of its - * arms and keep their sentence and their path. The list operators keep their - * lists (`$in: []` / `$nin: []` included), and the null predicate, a - * `{ $field }` reference as the whole comparand and every scalar pass, because - * the face passes them. - */ -function refuseNestedRelationComparands( - node: unknown, - ctx: z.RefinementCtx, - path: (string | number)[] = [], - insideRelation = false, -): void { - if (!isAnalyticsFilterObject(node)) return; - for (const [key, spec] of Object.entries(node)) { - if (key === '$and' || key === '$or') { - if (Array.isArray(spec)) { - spec.forEach((member, index) => - refuseNestedRelationComparands(member, ctx, [...path, key, index], insideRelation)); - } - continue; - } - if (key === '$not') { - refuseNestedRelationComparands(spec, ctx, [...path, key], insideRelation); - continue; - } - if (key.startsWith('$')) continue; - if (isAnalyticsNestedRelationSpec(spec)) { - refuseNestedRelationComparands(spec, ctx, [...path, key], true); - continue; - } - if (!insideRelation) continue; // `FilterConditionSchema` judges this entry itself - // [#20116] Every slot the analytics door hands to the query faces, asked of - // the one function `FilterConditionSchema`'s own walk asks: an implicit - // comparand, or each operator of an operator map. - if (!isAnalyticsFilterObject(spec)) { - reportQueryFaceRefusals(ctx, [...path, key], key, undefined, spec, FieldOperatorsSchema); - continue; - } - for (const [op, comparand] of Object.entries(spec)) { - if (!op.startsWith('$')) continue; - reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema); - } - } -} - -/** - * The optional filter both analytics carriers declare — `DatasetSchema.filter` - * and `DatasetMeasureSchema.filter` — which is `FilterConditionSchema` plus - * {@link refuseNestedRelationComparands}. Every other schema that carries a - * `FilterCondition` keeps the shared schema's reach. - * - * The check sits on the OPTIONAL wrapper, not on `FilterConditionSchema` - * itself: refining the recursive schema would clone it, and the published JSON - * Schema would then inline a second copy of the condition beside the `$ref` it - * carries today. On the wrapper the condition keeps its identity, so the - * published body of `ui/Dataset` and `ui/DatasetMeasure` is unchanged, and the - * new rule is recorded as a dropped refinement at each carrier's `filter` in - * `dropped-refinements.baseline.json` (`z.toJSONSchema()` has no projection for - * it). An absent filter reaches the check as `undefined`, which the walk - * passes. - */ -function analyticsCarrierFilter() { - return FilterConditionSchema.optional().superRefine((filter, ctx) => - refuseNestedRelationComparands(filter, ctx)); -} - /** * Dimension — a groupable axis (e.g. "region", "close_date by quarter"). */ @@ -353,7 +191,7 @@ export const DatasetMeasureSchema = lazySchema(() => strictObject({ * Measure-scoped filter (e.g. only won deals for "won_amount"). [#20080] A * list in the equality slot inside a nested relation is refused on save, as * the analytics door refuses it on chart — see - * {@link refuseNestedRelationComparands}. + * {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). */ filter: analyticsCarrierFilter().meta({ title: 'Filter' }), /** @@ -516,7 +354,7 @@ export const DatasetSchema = lazySchema(() => strictObject({ * Definition-level filter (the dataset's intrinsic scope, e.g. non-deleted). * [#20080] A list in the equality slot inside a nested relation is refused * on save, as the analytics door refuses it on chart — see - * {@link refuseNestedRelationComparands}. + * {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). */ filter: analyticsCarrierFilter().describe('Intrinsic dataset scope filter'), From cfc0d7415822bfdb104ec6587d94a60ebd9a9bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:16:24 +0000 Subject: [PATCH 2/8] fix(spec): the save doors refuse the comparand-type face's cells, and a widget filter is an analytics carrier M-type: `reportQueryFaceRefusals` asks the comparand-type face (`normalizeFilterComparandTypes`) after the shape face, read-only, so a plain object where a literal belongs, a Map, a class instance, undefined, a function, a Symbol or a bigint beyond 2^53 is refused on save at every reach the save doors have, in the face's words less its location. One slot raises one refusal, in the query doors' order (shape, type, flag). M-widget (dashboard half): `DashboardWidgetSchema.filter` declares the analytics carrier filter, so a comparand the analytics door refuses inside a nested relation is refused on save, as on the dataset carriers. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../src/data/filter-save-door-refusals.ts | 134 +++++++++++++++--- packages/spec/src/data/filter.zod.ts | 56 ++++++-- .../spec/src/ui/analytics-carrier-filter.ts | 73 +++++++--- packages/spec/src/ui/dashboard.zod.ts | 12 +- 4 files changed, 226 insertions(+), 49 deletions(-) diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index cdf29b5756d..4dceb3156c9 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -10,9 +10,11 @@ * `$and` / `$or` / `$not` member — the shared comparand face's reach, which * every schema carrying a `FilterCondition` gets; * - the analytics carriers' nested-relation walk - * (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), over the - * entries INSIDE a nested-relation condition, which the analytics `where` - * door flattens to dotted members and judges like any other entry. + * (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), + * over the entries INSIDE a nested-relation condition, which the analytics + * `where` door flattens to dotted members and judges like any other entry. + * Every filter charted through that door declares it: a dataset `filter`, a + * measure `filter` and a dashboard widget `filter`. * * So a slot is judged one way whichever reach finds it, and a rule added here * reaches both. @@ -32,6 +34,24 @@ * `driver-mongodb`, the read-scope compiler, the analytics `where` door): the * comparand is not a boolean (#5347 / #5369). * + * The comparand-TYPE face (`normalizeFilterComparandTypes`, + * `./filter-comparand-type.ts`, the #7872 ruling's accepted set `string | + * number | bigint | boolean | null | Date`) is called read-only the same way, + * on the field entry the query doors hand it: it refuses a plain object where + * a scalar belongs (`{ $eq: { a: 1 } }`), a `Map`, a class instance, a + * function, a Symbol, `undefined`, and a bigint beyond ±2^53, as a comparand + * and as a list member — and passes what it passes: the six accepted types, a + * `{ $field }` reference, and a bigint within ±2^53, which it would narrow to + * its number on a query and which the save door keeps as written. Before it, + * every save door accepted each of those and the analytics door refused each + * on chart. The document is judged, never rewritten. + * + * One slot raises ONE refusal: the first the query doors give, in their order + * — the shape face, then the type face, then the flag rule (`parseFilterAST`, + * the engine seam and the analytics door all run them in that order). So + * `$null: { a: 1 }` reads as the type face's refusal, as it does on chart, and + * never as two issues at one path. + * * ## The words * * - The equality and `$ne` slots: the face's own sentence, from the builders @@ -45,6 +65,9 @@ * wording for those shapes. * - A non-boolean flag: the query faces' sentence (see * {@link nonBooleanFlagComparandMessage}). + * - A comparand the type face refuses: the type face's own sentence, less its + * ` at ` clause (see {@link comparandTypeRefusalAtSave}). Nothing of it + * is restated here. * * None carries the face's ` at ` clause: the issue's own `path` carries * the location, which a refinement cannot see from inside the document. @@ -62,6 +85,7 @@ import type { z } from 'zod'; import { assertListComparandShapes } from './filter-comparand-shape'; +import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { IN_OPERATOR_SPELLINGS, NIN_OPERATOR_SPELLINGS, @@ -269,11 +293,85 @@ function nonBooleanFlagComparandMessage(op: string, field: string, value: unknow } /** - * Raise, as `custom` issues under `slotPath`, every refusal the query faces - * give for ONE comparand slot: the comparand-shape face's verdict on the slot, - * and — for the two boolean flags, which that face does not judge — the flag - * rule. `op` is `undefined` for an implicit-equality comparand, and `slotPath` - * is then the field's own path. `slots` is `FieldOperatorsSchema`. + * Ask the comparand-TYPE face about one field entry (`{ stage: }`), the + * hand-over the analytics door and the engine seam make. Returns the face's + * refusal, or `undefined` when it accepts. Read-only: the face's narrowed copy + * (a bigint within ±2^53, as its number) is discarded, because the save door + * judges the document and never rewrites it. + * + * Only the face's own envelope (`INVALID_FILTER`) is read as a verdict, as in + * {@link comparandShapeFaceRefusal}. + */ +function comparandTypeFaceRefusal(entry: Record): Error | undefined { + try { + normalizeFilterComparandTypes(entry); + } catch (error) { + if ((error as { code?: unknown }).code === 'INVALID_FILTER') return error as Error; + throw error; + } + return undefined; +} + +/** + * The type face's refusal of ONE slot, located and in its own words, or + * `undefined` when the face passes the slot. + * + * - **The verdict** is the face's, twice over. On the whole field entry + * (`fieldSpec`) first, because the face classifies the entry before it + * judges an operator: a spec carrying a string `$field` is a field reference + * it steps around whole, whatever sits beside it. Then on the one-slot node, + * so each refused slot of an entry is reported at its own path. + * - **The location.** The face judges a list operator's members one by one and + * stops at the first it refuses; that member is found by asking the face + * about each member alone, and the issue sits at it (`stage.$in.1`), as the + * shape arms' null member does. Anything else sits at the slot. + * - **The words** are the face's message with its ` at where.` clause + * removed — the one clause this door cannot write truthfully, since a + * refinement cannot see where it sits in the document (the issue's `path` + * says). The clause removed is the one the face was handed (`where`, its + * default root, plus this slot), so nothing is parsed out of the text. Should + * the face ever spell its location differently, the clause is not found and + * the face's whole message is reported, location included, rather than a + * guess — `filter-save-door-face-parity.test.ts` fails on that text. + */ +function comparandTypeRefusalAtSave( + field: string, + op: string | undefined, + comparand: unknown, + fieldSpec: unknown, +): SaveDoorRefusal | undefined { + if (comparandTypeFaceRefusal({ [field]: fieldSpec }) === undefined) return undefined; + const slot = (value: unknown): Record => + (op === undefined ? { [field]: value } : { [field]: { [op]: value } }); + const face = comparandTypeFaceRefusal(slot(comparand)); + if (face === undefined) return undefined; + let at: number[] = []; + let location = op === undefined ? `where.${field}` : `where.${field}.${op}`; + if (op !== undefined && Array.isArray(comparand)) { + const member = comparand.findIndex((value) => comparandTypeFaceRefusal(slot([value])) !== undefined); + if (member !== -1) { + at = [member]; + location = `${location}[${member}]`; + } + } + const clause = ` at ${location} `; + const message = face.message.includes(clause) ? face.message.replace(clause, ' ') : face.message; + return { at, message }; +} + +/** + * Raise, as ONE `custom` issue under `slotPath`, the first refusal the query + * faces give for ONE comparand slot, in their order: the comparand-shape + * face's verdict on the slot, then the comparand-type face's, then — for the + * two boolean flags, which neither face judges as a flag — the flag rule. `op` + * is `undefined` for an implicit-equality comparand, and `slotPath` is then + * the field's own path. `slots` is `FieldOperatorsSchema`. `fieldSpec` is the + * whole value of the field entry the slot sits in — the operator map, or the + * implicit comparand itself (the default) — which the type face classifies + * before it judges the slot. + * + * Returns whether the slot was refused, so a walk with arms of its own on the + * same slot can stay silent rather than raise a second issue at one path. */ export function reportQueryFaceRefusals( ctx: z.RefinementCtx, @@ -282,14 +380,16 @@ export function reportQueryFaceRefusals( op: string | undefined, comparand: unknown, slots: OperatorSlots, -): void { - const refusals: SaveDoorRefusal[] = []; - const face = comparandShapeFaceRefusal(op === undefined ? { [field]: comparand } : { [field]: { [op]: comparand } }); - if (face) refusals.push(comparandShapeRefusalAtSave(slots, field, op, comparand, face)); - if (op !== undefined && BOOLEAN_FLAG_OPERATORS.has(op) && typeof comparand !== 'boolean') { - refusals.push({ at: [], message: nonBooleanFlagComparandMessage(op, field, comparand) }); - } - for (const refusal of refusals) { - ctx.addIssue({ code: 'custom', path: [...slotPath, ...refusal.at], message: refusal.message }); + fieldSpec: unknown = op === undefined ? comparand : { [op]: comparand }, +): boolean { + let refusal: SaveDoorRefusal | undefined; + const shapeFace = comparandShapeFaceRefusal(op === undefined ? { [field]: comparand } : { [field]: { [op]: comparand } }); + if (shapeFace) refusal = comparandShapeRefusalAtSave(slots, field, op, comparand, shapeFace); + refusal ??= comparandTypeRefusalAtSave(field, op, comparand, fieldSpec); + if (refusal === undefined && op !== undefined && BOOLEAN_FLAG_OPERATORS.has(op) && typeof comparand !== 'boolean') { + refusal = { at: [], message: nonBooleanFlagComparandMessage(op, field, comparand) }; } + if (refusal === undefined) return false; + ctx.addIssue({ code: 'custom', path: [...slotPath, ...refusal.at], message: refusal.message }); + return true; } diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 959113afc2b..78ec1e0ebaf 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1702,6 +1702,21 @@ function isPlainFilterNode(value: unknown): value is Record { ); } +/** + * [#20116] Is this field value a COMPARAND, as the comparand-type face + * classifies it? Everything but filter structure is — and structure is a PLAIN + * object only, prototype `Object.prototype` or `null` (the face's `isFilterNode`, + * the convention `driver-sql` shares since #5134, and the analytics door's + * nested-relation test). A `Map`, a class instance or a `Date` answers + * `typeof x === 'object'` while being data: `{ stage: new Map() }` is a + * comparand the type face refuses, never an empty nested relation. + */ +function isFieldValueComparand(value: unknown): boolean { + if (!isPlainFilterNode(value)) return true; + const proto = Object.getPrototypeOf(value); + return proto !== Object.prototype && proto !== null; +} + /** * Walk one condition node and report every comparand this authoring door * refuses — the bare date-range PRESET names in an ordering position (#8793), @@ -1732,6 +1747,20 @@ function isPlainFilterNode(value: unknown): value is Record { * `$in: []` / `$nin: []` and a whitespace endpoint all keep passing, because * the face passes them. The flags, which that face does not judge, use the * one predicate every flag face uses: `typeof comparand !== 'boolean'`. + * - **The comparand-TYPE face too** (`normalizeFilterComparandTypes`, the + * #7872 accepted set), asked by the same function after the shape face: a + * plain object where a literal belongs (`{ $eq: { a: 1 } }`), a `Map`, a + * class instance, a function, a Symbol, `undefined` or a bigint beyond ±2^53 + * — as the comparand or as a list member — is refused on save, as every + * query face refuses it. Measured on `origin/main` `17bd3187` before this + * arm: every save door accepted `{ stage: { $eq: { a: 1 } } }`, + * `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, while the type face + * and the analytics door refused each with `INVALID_FILTER` / 400. A `Date`, + * a `{ $field }` reference, a `{placeholder}` string resolved at request time + * and a bigint within ±2^53 keep passing, because the face passes them. So + * that a `Map` or a class instance reaches the face at all, a field value is + * a comparand unless it is a PLAIN object ({@link isFieldValueComparand}, + * the face's own structure test). * - **The words** are chosen in that module: the face's own sentence where * the two doors already share a builder, the enforced * operator slot's sentence where `FieldOperatorsSchema` already prints one for @@ -1827,13 +1856,14 @@ function checkFilterConditionComparands( for (const [key, value] of Object.entries(node)) { if (key.startsWith('$')) continue; // $and/$or/$not re-parse; other $ keys stay unjudged - // [#19889, #20116] An IMPLICIT comparand — a scalar, a `Date` or an array - // (the equality slot's `{ field: [...] }`, the empty list included) — asked - // of the query faces. Judged on this node's OWN field entries only - // (`depth` 0), the face's exact reach: its walk never descends a field spec - // that has no `$` key, so nothing inside a nested-relation condition is - // refused there, and nothing is refused here. See the docblock. - if (!isPlainFilterNode(value)) { + // [#19889, #20116] An IMPLICIT comparand — a scalar, a `Date`, an array + // (the equality slot's `{ field: [...] }`, the empty list included), or a + // `Map` / class instance, which the type face calls data — asked of the + // query faces. Judged on this node's OWN field entries only (`depth` 0), + // the face's exact reach: its walk never descends a field spec that has no + // `$` key, so nothing inside a nested-relation condition is refused there, + // and nothing is refused here. See the docblock. + if (isFieldValueComparand(value)) { if (depth === 0) reportQueryFaceRefusals(ctx, [...path, key], key, undefined, value, FieldOperatorsSchema); continue; } @@ -1848,9 +1878,15 @@ function checkFilterConditionComparands( // [#20116] Every operator slot asked of the query faces, on the same // reach as the implicit form above: the comparand-shape face's verdict // (the equality and `$ne` slots, the ordering `null` carve-out, the list - // operators' shape, null-member and endpoint rules) and the boolean - // flags'. An operator neither judges passes through untouched. - if (depth === 0) reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema); + // operators' shape, null-member and endpoint rules), the comparand-type + // face's (a plain object, `Map`, class instance, `undefined` … where a + // literal belongs), and the boolean flags'. An operator none of them + // judges passes through untouched. A slot they refuse raises that one + // issue, and this walk's own arms below stay silent on it: one defect, + // one issue at one path. + if (depth === 0 && reportQueryFaceRefusals(ctx, [...path, key, op], key, op, comparand, FieldOperatorsSchema, value)) { + continue; + } if (op === FILTER_TEXT_COMPARAND_OPERATOR && isRefusedTextComparand(comparand)) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/ui/analytics-carrier-filter.ts b/packages/spec/src/ui/analytics-carrier-filter.ts index 08a98523816..9c461fda4d0 100644 --- a/packages/spec/src/ui/analytics-carrier-filter.ts +++ b/packages/spec/src/ui/analytics-carrier-filter.ts @@ -1,20 +1,26 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The filter slot of every ANALYTICS carrier — a filter that is charted through - * the analytics `where` door — declared once: `FilterConditionSchema` plus the - * nested-relation walk that door's reach needs (see {@link analyticsCarrierFilter}). + * The filter slot of every ANALYTICS carrier — a stored filter that is charted + * through the analytics `where` door — declared once: `FilterConditionSchema` + * plus the nested-relation walk that door's reach needs (see + * {@link analyticsCarrierFilter}). Three carriers declare it: `DatasetSchema`'s + * `filter` and `DatasetMeasureSchema`'s `filter` (`./dataset.zod.ts`, #20080), + * and a dashboard widget's `filter` (`./dashboard.zod.ts`, #20116), which the + * dataset executor ANDs into the same query as the selection's + * `runtimeFilter`. * - * A module of its own, and outside the `ui` barrel, so the carriers in - * `./dataset.zod.ts` and `./dashboard.zod.ts` share one declaration without it - * becoming published API. + * A module of its own, and outside the `ui` barrel, so the carriers share one + * declaration without it becoming published API. It moved here verbatim from + * `./dataset.zod.ts` when the widget became its second file's carrier; the two + * dataset carriers' published JSON Schema did not move with it. */ import type { z } from 'zod'; import { FieldOperatorsSchema, FilterConditionSchema } from '../data/filter.zod'; import { reportQueryFaceRefusals } from '../data/filter-save-door-refusals'; -// ── [#20080] The analytics door's reach, on the two analytics carriers ────── +// ── [#20080] The analytics door's reach, on the analytics carriers ────────── /** * A node the analytics `where` door walks: a plain object, not `null`, not an @@ -39,7 +45,21 @@ function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 919d93f07fa..6dffeea2a7c 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -6,6 +6,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; import { FilterConditionSchema } from '../data/filter.zod'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { DateGranularity } from '../data/query.zod'; import { DATE_MACRO_WRAPPED_RE, isDateMacroToken } from '../data/date-macros.zod'; import { DATE_RANGE_PRESETS } from '../data/date-range-presets'; @@ -868,8 +869,15 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ actionType: retiredKey(WIDGET_ACTION_RETIRED('actionType')), actionIcon: retiredKey(WIDGET_ACTION_RETIRED('actionIcon')), - /** Presentation-scope filter (MongoDB-style), ANDed into the dataset query as `runtimeFilter`. */ - filter: FilterConditionSchema.optional().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }), + /** + * Presentation-scope filter (MongoDB-style), ANDed into the dataset query as + * `runtimeFilter`. [#20116] It is charted through the analytics `where` door + * like a dataset's own `filter`, so it is an analytics carrier: a comparand + * that door refuses INSIDE a nested relation + * (`{ acct: { stage: { $in: ['won', null] } } }`) is refused on save too — + * see {@link analyticsCarrierFilter} (`./analytics-carrier-filter.ts`). + */ + filter: analyticsCarrierFilter().describe('Presentation-scope filter (runtimeFilter)').meta({ title: 'Filter' }), /** * Period-over-period comparison window. From eaf7a9257d3c627dbee567df909d3e4eaa7a7e16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:30:57 +0000 Subject: [PATCH 3/8] test(spec): the enumerating pin asks the comparand-type face, on every analytics carrier Stage 1's table-driven pin now counts the type face among the query faces (operator arms derived from FieldOperatorsSchema, every declared operator judged by the type face), walks the type face's own conformance table, pins its words less the location and the one-issue-per-slot order, and runs the nested-relation table on the dashboard widget filter too. The two report runtimeFilter carriers are listed as expected-open rows. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../data/filter-save-door-face-parity.test.ts | 402 +++++++++++++++--- 1 file changed, 352 insertions(+), 50 deletions(-) diff --git a/packages/spec/src/data/filter-save-door-face-parity.test.ts b/packages/spec/src/data/filter-save-door-face-parity.test.ts index a9d0a8af5f4..3dd4e06f99b 100644 --- a/packages/spec/src/data/filter-save-door-face-parity.test.ts +++ b/packages/spec/src/data/filter-save-door-face-parity.test.ts @@ -4,9 +4,20 @@ * [#20116] The SAVE door (`FilterConditionSchema`) refuses exactly the comparand * slots the QUERY faces refuse — at the top level and in every `$and` / `$or` / * `$not` member, and not inside a nested-relation condition, which the face - * never descends. The two dataset carriers, charted through the analytics - * `where` door that DOES descend a relation, refuse the same slots inside one - * (§5), asking the same function. + * never descends. The analytics carriers (a dataset `filter`, a measure + * `filter` and, since stage 2, a dashboard widget `filter`), charted through + * the analytics `where` door that DOES descend a relation, refuse the same + * slots inside one (§5), asking the same function. + * + * Stage 2 adds the comparand-TYPE face (`normalizeFilterComparandTypes`) to + * "the query faces": measured on `origin/main` `17bd3187`, every save door + * accepted `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a + * `Map` comparand, top level and nested, while that face and the analytics + * door refused each with `INVALID_FILTER` / 400; and a dashboard widget + * `filter` accepted `{ acct: { stage: { $in: ['won', null] } } }` and #20080's + * nested equality lists, which the analytics door refuses on chart. §1 and §5 + * ask all three rules; §6 pins the type face's words and table; §7 the + * carriers. * * Measured on `origin/main` `af32cf9a` before the change: `DatasetSchema` * (filter and measure filter), a dashboard widget `filter` and a report @@ -35,11 +46,16 @@ import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + import { StandardErrorCode } from '../api/errors.zod'; +import { analyticsCarrierFilter } from '../ui/analytics-carrier-filter'; import { DashboardSchema } from '../ui/dashboard.zod'; import { DatasetMeasureSchema, DatasetSchema } from '../ui/dataset.zod'; import { ReportSchema } from '../ui/report.zod'; import { assertListComparandShapes } from './filter-comparand-shape'; +import { ComparandTypeProbe, FILTER_COMPARAND_TYPE_CASES } from './filter-comparand-type-conformance'; +import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { isRefusedTextComparand } from './filter-text-comparand'; import { FieldOperatorsSchema, FilterConditionSchema } from './filter.zod'; @@ -56,6 +72,34 @@ function faceRefusal(where: unknown): (Error & { code?: string; status?: number return undefined; } +/** The comparand-TYPE face's refusal of `where`, or `undefined` when it accepts (its narrowed copy discarded). */ +function typeFaceRefusal(where: unknown): (Error & { code?: string; status?: number }) | undefined { + try { + normalizeFilterComparandTypes(where); + } catch (error) { + return error as Error & { code?: string; status?: number }; + } + return undefined; +} + +/** A test-name rendering that survives a bigint, a `Map`, `undefined` and a function. */ +function show(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => { + if (typeof v === 'bigint') return `${v}n`; + if (v === undefined) return 'undefined'; + if (v instanceof Map) return 'Map'; + if (typeof v === 'function' || typeof v === 'symbol') return String(v); + return v; + }); +} + +/** Filter STRUCTURE as the type face classifies it: a PLAIN object, prototype `Object.prototype` or `null`. */ +function isPlainObject(value: unknown): boolean { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + /** Issues whose path starts with `prefix` (dot-joined). */ function issuesUnder(result: Parsed, prefix: string): Issue[] { if (result.success) return []; @@ -119,11 +163,31 @@ const BATTERY: ReadonlyArray = [ ['a pair with a { $field } MIN', [{ $field: 'a' }, 5]], ['a list of one { $field }', [{ $field: 'a' }]], ['a nested list', [[1]]], + // Stage 2 — the comparand-TYPE face's arms, and the neighbours it passes. + ['a Map', new Map([['a', 1]])], + ['a class instance', new ComparandTypeProbe()], + ['a function', () => 1], + ['a Symbol', Symbol('x')], + ['a { $field } with a non-string name', { $field: 5 }], + ['an empty object', {}], + ['a bigint within 2^53', 5n], + ['a bigint beyond 2^53', 2n ** 60n], + ['a list holding a plain object', ['won', { a: 1 }]], + ['a list holding a Map', [new Map()]], + ['a list holding undefined', [1, undefined]], + ['a list holding a bigint beyond 2^53', [2n ** 60n]], + ['a pair with a plain-object MAX', [1, { a: 1 }]], + ['a pair of bigints within 2^53', [1n, 9n]], ]; -/** What the query faces answer for one operator slot: the shape face, and the flag rule. */ +/** + * What the query faces answer for one operator slot: the shape face, the type + * face, and the flag rule — the three `parseFilterAST`, the engine seam and + * the analytics door run, in that order. + */ function queryFacesRefuse(op: string, comparand: unknown): boolean { if (faceRefusal({ f: { [op]: comparand } })) return true; + if (typeFaceRefusal({ f: { [op]: comparand } })) return true; return BOOLEAN_SLOTS.includes(op) && typeof comparand !== 'boolean'; } @@ -141,7 +205,7 @@ const POSITIONS: ReadonlyArray { - it('the table is derived, not hand-listed, and covers every arm the face and the flag rule judge', () => { + it('the table is derived, not hand-listed, and covers every arm the faces and the flag rule judge', () => { // The vocabulary is the enforced copy's, so a new operator joins the table. expect(OPERATORS).toEqual(expect.arrayContaining(['$eq', '$ne', '$gt', '$in', '$nin', '$between', '$null', '$exists'])); expect(BOOLEAN_SLOTS.sort()).toEqual(['$exists', '$null']); @@ -149,6 +213,12 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the // the guard against a battery that silently stopped reaching an arm. const faceJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => faceRefusal({ f: { [op]: c } }))); expect(faceJudged.sort()).toEqual(['$between', '$eq', '$gt', '$gte', '$in', '$lt', '$lte', '$ne', '$nin']); + // [stage 2] The TYPE face judges every declared operator — its own test + // reconciles its scalar/list split against this same vocabulary — so every + // one of them must refuse some battery shape here, as a scalar comparand or + // as a list member. + const typeJudged = OPERATORS.filter((op) => BATTERY.some(([, c]) => typeFaceRefusal({ f: { [op]: c } }))); + expect(typeJudged.sort()).toEqual([...OPERATORS].sort()); }); for (const [position, wrap, prefix] of POSITIONS) { @@ -172,14 +242,20 @@ describe('#20116 §1 — the enumeration: the save door refuses exactly what the } it('the implicit-equality slot, every battery shape that is a comparand there', () => { + let refused = 0; for (const [label, comparand] of BATTERY) { - if (comparand !== null && typeof comparand === 'object' && !Array.isArray(comparand) && !(comparand instanceof Date)) { - continue; // a plain object in this slot is a nested condition, not a comparand - } - const expected = faceRefusal({ f: comparand }) !== undefined; + // A PLAIN object in this slot is a nested condition or an operator map, + // not a comparand — the type face's own classification. A `Map` or a + // class instance IS a comparand here, and the type face judges it. + if (isPlainObject(comparand)) continue; + const expected = faceRefusal({ f: comparand }) !== undefined || typeFaceRefusal({ f: comparand }) !== undefined; const got = issuesUnder(FilterConditionSchema.safeParse({ f: comparand }), 'f'); + if (expected) refused += 1; expect(got.length > 0, label).toBe(expected); } + // The implicit slot's refusals: every list, `undefined`, a Map, a class + // instance, a function, a Symbol and the bigint beyond 2^53. + expect(refused).toBeGreaterThanOrEqual(15); }); it('inside a nested-relation condition the door answers as the face does — it never descends one', () => { @@ -302,7 +378,7 @@ describe('#20116 §2 — each refusal: issue code, path and the prescription', ( for (const [, comparand] of BATTERY) { const result = FilterConditionSchema.safeParse({ f: { [op]: comparand } }); for (const issue of issuesUnder(result, `f.${op}`)) { - expect(issue.message, `${op} ← ${JSON.stringify(comparand)}`).not.toContain(' at where.'); + expect(issue.message, `${op} ← ${show(comparand)}`).not.toContain(' at where.'); } } } @@ -347,6 +423,10 @@ describe('#20116 §3 — the stored carriers', () => { name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter, }); + const joinedReport = (runtimeFilter: unknown) => ({ + name: 'pipeline_joined', label: 'Pipeline', type: 'joined', + blocks: [{ name: 'won', label: 'Won', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter }], + }); /** The collector's members, each with the slot path it is refused at. */ const MEMBERS: ReadonlyArray, slot: string]> = [ @@ -358,15 +438,24 @@ describe('#20116 §3 — the stored carriers', () => { [{ stage: { $in: ['won', null] } }, 'stage.$in.1'], [{ amount: { $between: [null, 5] } }, 'amount.$between.0'], [{ stage: { $ne: ['won', 'lost'] } }, 'stage.$ne'], + // [stage 2] M-type: the comparand-TYPE face's cells. + [{ stage: { $eq: { a: 1 } } }, 'stage.$eq'], + [{ stage: { $in: [{ a: 1 }] } }, 'stage.$in.0'], + [{ stage: { $eq: new Map([['a', 1]]) } }, 'stage.$eq'], + [{ stage: new Map([['a', 1]]) }, 'stage'], + [{ stage: { $nin: ['lost', new Map()] } }, 'stage.$nin.1'], + [{ amount: { $gt: 2n ** 60n } }, 'amount.$gt'], + [{ stage: undefined }, 'stage'], ]; - it.each(MEMBERS)('%j — refused by the dataset filter, a measure filter, a widget filter and a report runtimeFilter', (where, slot) => { + it.each(MEMBERS.map(([where, slot]) => [show(where), where, slot] as const))('%s — refused by the dataset filter, a measure filter, a widget filter and both report runtimeFilters', (_label, where, slot) => { const expected = issueAt(FilterConditionSchema.safeParse(where), slot).message; expect(issueAt(DatasetSchema.safeParse(dataset({ filter: where })), `filter.${slot}`).message).toBe(expected); const measure = dataset({ measures: [{ name: 'deal_count', aggregate: 'count', filter: where }] }); expect(issueAt(DatasetSchema.safeParse(measure), `measures.0.filter.${slot}`).message).toBe(expected); expect(issueAt(DashboardSchema.safeParse(dashboard(where)), `widgets.0.filter.${slot}`).message).toBe(expected); expect(issueAt(ReportSchema.safeParse(report(where)), `runtimeFilter.${slot}`).message).toBe(expected); + expect(issueAt(ReportSchema.safeParse(joinedReport(where)), `blocks.0.runtimeFilter.${slot}`).message).toBe(expected); }); it('CONTROL — the same carriers publish the boolean flags and keep them', () => { @@ -411,10 +500,28 @@ describe('#20116 §4 — what stays accepted, at both doors', () => { ['every one of the above under $and / $or / $not', { $and: [{ stage: { $ne: null } }], $or: [{ amount: { $gt: { $field: 'b' } } }], $not: { stage: { $in: [] } }, }], + // [stage 2] What the TYPE face passes: its six accepted types, a + // `{ $field }` reference, and the `{placeholder}` strings the engine + // resolves only at request time (after both faces have run). + ['$eq: a { $field } reference — not a literal', { amount: { $eq: { $field: 'budget' } } }], + ['$gte: a {placeholder} date macro, resolved at request time', { closed_at: { $gte: '{today}' } }], + ['an implicit {current_user_id}, resolved at request time', { owner_id: '{current_user_id}' }], + ['$in: {placeholder} members', { owner_id: { $in: ['{current_user_id}', 'usr_1'] } }], + ['$eq: a bigint within 2^53 — KEPT as written, never narrowed on save', { qty: { $eq: 100n } }], + ['$in: bigints within 2^53', { qty: { $in: [100n, 2n ** 53n] } }], + ['$between: Dates', { closed_at: { $between: [DAY, DAY] } }], + ['an implicit Date', { closed_at: DAY }], + ['an implicit boolean, number and null', { active: true, qty: 0, note: null }], + ['a spec the type face classifies as a { $field } reference is stepped around whole, as that face does', { + amount: { $field: 'budget', $gt: new Map() }, + }], + ['a Map INSIDE a nested relation — neither face descends one', { acct: { stage: new Map([['a', 1]]) } }], + ['a plain object under $eq INSIDE a nested relation — neither face descends one', { acct: { stage: { $eq: { a: 1 } } } }], ])('%s', (_label, where) => { expect(faceRefusal(where)).toBeUndefined(); + expect(typeFaceRefusal(where)).toBeUndefined(); const result = FilterConditionSchema.safeParse(where); - expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.success, show(result.error?.issues)).toBe(true); // Accepted means KEPT: the door returns the document it was given. expect(result.data).toEqual(where); }); @@ -424,12 +531,13 @@ describe('#20116 §4 — what stays accepted, at both doors', () => { // §5 Inside a nested relation, the ANALYTICS carriers answer as the analytics door // --------------------------------------------------------------------------- -describe('#20116 §5 — inside a nested relation, the dataset carriers refuse what the analytics door refuses', () => { +describe('#20116 §5 — inside a nested relation, the analytics carriers refuse what the analytics door refuses', () => { // The analytics `where` door flattens a nested relation to dotted members and // hands each entry to the same query faces it hands a top-level entry. The - // dataset carriers' own walk (`refuseNestedRelationComparands`, #20207's) - // reaches those entries and asks the same function the shared walk asks, so - // the table is §1's, one relation down, on both carriers. + // analytics carriers' own walk (`refuseNestedRelationComparands`, #20207's, + // `ui/analytics-carrier-filter.ts` since stage 2) reaches those entries and + // asks the same function the shared walk asks, so the table is §1's, one + // relation down, on every carrier. const dataset = (filter: unknown) => ({ name: 'deals_ds', label: 'Deals', @@ -438,25 +546,75 @@ describe('#20116 §5 — inside a nested relation, the dataset carriers refuse w measures: [{ name: 'deal_count', aggregate: 'count' }], filter, }); + const dashboard = (filter: unknown) => ({ + name: 'sales', + label: 'Sales', + widgets: [{ id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'], filter }], + }); - it.each([ + /** Every analytics carrier: its parse, and where its filter sits in the document. */ + const CARRIERS: ReadonlyArray Parsed, at: string]> = [ + ['dataset filter', (filter) => DatasetSchema.safeParse(dataset(filter)), 'filter'], + ['measure filter', (filter) => DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), 'filter'], + ['dashboard widget filter', (filter) => DashboardSchema.safeParse(dashboard(filter)), 'widgets.0.filter'], + ]; + + /** + * EXPECTED-OPEN — charted through the same analytics door, and NOT carriers + * yet: a report's `runtimeFilter` and a joined report block's. That half of + * the collector stays on #20116 (its regions are held by #20161's PR). The + * flip is one line per slot in `ui/report.zod.ts` — + * `runtimeFilter: analyticsCarrierFilter()…` — after which these rows go red + * and move into `CARRIERS` above. + */ + const EXPECTED_OPEN: ReadonlyArray Parsed, at: string]> = [ + ['report runtimeFilter', (runtimeFilter) => ReportSchema.safeParse({ + name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter, + }), 'runtimeFilter'], + ['joined report block runtimeFilter', (runtimeFilter) => ReportSchema.safeParse({ + name: 'pipeline_joined', label: 'Pipeline', type: 'joined', + blocks: [{ name: 'won', label: 'Won', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter }], + }), 'blocks.0.runtimeFilter'], + ]; + + /** The collector's nested members, each with the slot it is refused at inside the relation. */ + const NESTED_MEMBERS: ReadonlyArray }, slot: string]> = [ + [{ acct: { stage: { $null: 'x' } } }, 'acct.stage.$null'], + [{ acct: { stage: { $exists: 'false' } } }, 'acct.stage.$exists'], + [{ acct: { stage: { $null: null } } }, 'acct.stage.$null'], + [{ acct: { amount: { $gt: null } } }, 'acct.amount.$gt'], + [{ acct: { stage: { $in: 'won' } } }, 'acct.stage.$in'], + [{ acct: { stage: { $in: ['won', null] } } }, 'acct.stage.$in.1'], + [{ acct: { amount: { $between: [null, 5] } } }, 'acct.amount.$between.0'], + [{ acct: { stage: { $ne: ['won', 'lost'] } } }, 'acct.stage.$ne'], + // [stage 2] #20080's equality lists, M-widget's other named shape. + [{ acct: { region: ['a'] } }, 'acct.region'], + [{ acct: { region: { $eq: ['a'] } } }, 'acct.region.$eq'], + // [stage 2] M-type inside a relation. + [{ acct: { stage: { $eq: { a: 1 } } } }, 'acct.stage.$eq'], + [{ acct: { stage: { $in: [{ a: 1 }] } } }, 'acct.stage.$in.0'], + [{ acct: { stage: { $eq: new Map([['a', 1]]) } } }, 'acct.stage.$eq'], + [{ acct: { stage: new Map([['a', 1]]) } }, 'acct.stage'], + ]; + + const HOPS = [ ['one hop', (e: Record) => ({ acct: e }), 'acct.f'], ['two hops, under $or', (e: Record) => ({ $or: [{ acct: { owner: e } }] }), '$or.0.acct.owner.f'], - ] as const)('every operator × comparand cell, %s', (_label, wrap, prefix) => { + ] as const; + + it.each(HOPS)('every operator × comparand cell, %s, on every carrier', (_label, wrap, prefix) => { const mismatches: string[] = []; let refused = 0; for (const op of OPERATORS) { for (const [label, comparand] of BATTERY) { const expected = queryFacesRefuse(op, comparand) || textArmRefuses(op, comparand); const filter = wrap({ f: { [op]: comparand } }); - const scoped = issuesUnder(DatasetSchema.safeParse(dataset(filter)), `filter.${prefix}.${op}`); - const measure = issuesUnder( - DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), - `filter.${prefix}.${op}`, - ); if (expected) refused += 1; - if ((scoped.length > 0) !== expected || (measure.length > 0) !== expected) { - mismatches.push(`${op} ← ${label}: faces ${expected ? 'REFUSE' : 'ACCEPT'}, dataset ${scoped.length > 0 ? 'REFUSE' : 'ACCEPT'}, measure ${measure.length > 0 ? 'REFUSE' : 'ACCEPT'}`); + for (const [carrier, parse, at] of CARRIERS) { + const got = issuesUnder(parse(filter), `${at}.${prefix}.${op}`); + if ((got.length > 0) !== expected) { + mismatches.push(`${carrier}: ${op} ← ${label}: faces ${expected ? 'REFUSE' : 'ACCEPT'}, door ${got.length > 0 ? 'REFUSE' : 'ACCEPT'}`); + } } } } @@ -464,39 +622,183 @@ describe('#20116 §5 — inside a nested relation, the dataset carriers refuse w expect(refused).toBeGreaterThan(40); }); - it.each([ - [{ acct: { stage: { $null: 'x' } } }, 'acct.stage.$null'], - [{ acct: { stage: { $exists: 'false' } } }, 'acct.stage.$exists'], - [{ acct: { stage: { $null: null } } }, 'acct.stage.$null'], - [{ acct: { amount: { $gt: null } } }, 'acct.amount.$gt'], - [{ acct: { stage: { $in: 'won' } } }, 'acct.stage.$in'], - [{ acct: { stage: { $in: ['won', null] } } }, 'acct.stage.$in.1'], - [{ acct: { amount: { $between: [null, 5] } } }, 'acct.amount.$between.0'], - [{ acct: { stage: { $ne: ['won', 'lost'] } } }, 'acct.stage.$ne'], - ] as const)('the collector\'s nested member %j — one issue per carrier, in the top-level sentence', (filter, slot) => { - // The same words as the top-level form: the field named is the leaf, as - // the analytics door names it, and the issue path carries the relation. - const leaf = slot.split('.').slice(1).join('.'); - const topLevel = { [leaf.split('.')[0]!]: (filter.acct as Record)[leaf.split('.')[0]!] }; - const expected = issueAt(FilterConditionSchema.safeParse(topLevel), leaf).message; - const scoped = issueAt(DatasetSchema.safeParse(dataset(filter)), `filter.${slot}`); - expect(scoped.code).toBe('custom'); - expect(scoped.message).toBe(expected); - const measure = issueAt(DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), `filter.${slot}`); - expect(measure.message).toBe(expected); + it.each(HOPS)('the implicit-equality slot, %s, on every carrier — a Map or a class instance is the comparand it is', (_label, wrap, prefix) => { + let refused = 0; + for (const [label, comparand] of BATTERY) { + if (isPlainObject(comparand)) continue; // a nested condition or an operator map, not a comparand + const expected = faceRefusal({ f: comparand }) !== undefined || typeFaceRefusal({ f: comparand }) !== undefined; + if (expected) refused += 1; + for (const [carrier, parse, at] of CARRIERS) { + const got = issuesUnder(parse(wrap({ f: comparand } as Record)), `${at}.${prefix}`); + expect(got.length > 0, `${carrier}: ${label}`).toBe(expected); + } + } + expect(refused).toBeGreaterThanOrEqual(15); }); - it('CONTROL — the null predicate, references, empty lists and flags pass inside a relation, and are kept', () => { + it('EXPECTED-OPEN (#20116 remainder, #20161\'s regions) — the report carriers still save what the analytics door refuses inside a relation', () => { + for (const [filter] of NESTED_MEMBERS) { + for (const [carrier, parse] of EXPECTED_OPEN) { + const parsed = parse(filter); + expect(parsed.success, `${carrier} ${show(filter)} — flip this row: see EXPECTED_OPEN`).toBe(true); + } + } + }); + + it.each(NESTED_MEMBERS.map(([filter, slot]) => [show(filter), filter, slot] as const))( + 'the collector\'s nested member %s — one issue per carrier, in the top-level sentence', + (_label, filter, slot) => { + // The same words as the top-level form: the field named is the leaf, as + // the analytics door names it, and the issue path carries the relation. + const leaf = slot.split('.').slice(1).join('.'); + const topLevel = { [leaf.split('.')[0]!]: (filter.acct as Record)[leaf.split('.')[0]!] }; + const expected = issueAt(FilterConditionSchema.safeParse(topLevel), leaf).message; + // Every carrier refuses it on save, once, at its own path. + for (const [carrier, parse, at] of CARRIERS) { + const issue = issueAt(parse(filter), `${at}.${slot}`); + expect(issue.code, carrier).toBe('custom'); + expect(issue.message, carrier).toBe(expected); + } + }, + ); + + it('CONTROL — the null predicate, references, empty lists, flags, Dates and placeholders pass inside a relation on every carrier, and are kept', () => { const filter = { acct: { stage: { $ne: null, $in: [] }, - owner: { region: { $eq: null } }, + owner: { region: { $eq: null }, id: '{current_user_id}' }, amount: { $gt: { $field: 'floor' }, $between: [1, 9] }, active: { $null: false, $exists: true }, + closed_at: { $gte: DAY, $lt: '{today}' }, + qty: { $in: [1n, 2] }, }, }; - const parsed = DatasetSchema.safeParse(dataset(filter)); - expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); - expect(parsed.data!.filter).toEqual(filter); + for (const [carrier, parse, at] of CARRIERS) { + const parsed = parse(filter); + expect(parsed.success, `${carrier}: ${show(parsed.error?.issues)}`).toBe(true); + const kept = at.split('.').reduce((node, key) => (node as Record)[key], (parsed as { data?: unknown }).data); + expect(kept, carrier).toEqual(filter); + } + }); +}); + +// --------------------------------------------------------------------------- +// §6 The comparand-TYPE face: its words, its own table, one issue per slot +// --------------------------------------------------------------------------- + +describe('#20116 §6 — the comparand-TYPE face at the save door', () => { + /** The type face's message for `where`, with its ` at ` location clause removed. */ + function typeFaceSentenceWithoutLocation(where: unknown, facePath: string): string { + const face = typeFaceRefusal(where); + expect(face, `the type face accepted ${show(where)}`).toBeDefined(); + expect(face!.code).toBe(StandardErrorCode.enum.INVALID_FILTER); + expect(face!.status).toBe(400); + const location = ` at ${facePath} `; + // The clause is really there, exactly once, so removing it is not vacuous. + expect(face!.message.split(location)).toHaveLength(2); + return face!.message.replace(location, ' '); + } + + it.each([ + ['a plain object under $eq (M-type)', { stage: { $eq: { a: 1 } } }, 'stage.$eq', 'where.stage.$eq', 'a plain object ({"a":1})'], + ['a plain object under $ne', { stage: { $ne: { a: 1 } } }, 'stage.$ne', 'where.stage.$ne', 'a plain object'], + ['a Map under $gt', { amount: { $gt: new Map() } }, 'amount.$gt', 'where.amount.$gt', 'a Map instance'], + ['undefined under $contains', { name: { $contains: undefined } }, 'name.$contains', 'where.name.$contains', 'is undefined'], + ['a plain-object $in member (M-type)', { stage: { $in: ['won', { a: 1 }] } }, 'stage.$in.1', 'where.stage.$in[1]', 'a plain object'], + ['a Map $nin member', { stage: { $nin: [new Map()] } }, 'stage.$nin.0', 'where.stage.$nin[0]', 'a Map instance'], + ['a plain-object $between MAX', { amount: { $between: [1, { a: 1 }] } }, 'amount.$between.1', 'where.amount.$between[1]', 'a plain object'], + ['an implicit Map (M-type)', { stage: new Map([['a', 1]]) }, 'stage', 'where.stage', 'a Map instance'], + ['an implicit class instance', { qty: new ComparandTypeProbe() }, 'qty', 'where.qty', 'a ComparandTypeProbe instance'], + ['an implicit undefined', { owner: undefined }, 'owner', 'where.owner', 'is undefined'], + ['a function', { qty: { $eq: () => 1 } }, 'qty.$eq', 'where.qty.$eq', 'a function'], + ['a Symbol', { qty: { $eq: Symbol('x') } }, 'qty.$eq', 'where.qty.$eq', 'a Symbol'], + ['a bigint beyond 2^53', { qty: { $gt: 2n ** 60n } }, 'qty.$gt', 'where.qty.$gt', 'exceeds 2^53'], + ['a { $field } whose name is not a string', { amount: { $gt: { $field: 5 } } }, 'amount.$gt', 'where.amount.$gt', 'a plain object'], + ] as const)('%s — the type face\'s sentence, less its location, at the slot or member', (_label, where, issuePath, facePath, names) => { + const issue = issueAt(FilterConditionSchema.safeParse(where), issuePath); + expect(issue.code).toBe('custom'); + expect(issue.message).toBe(typeFaceSentenceWithoutLocation(where, facePath)); + expect(issue.message).toMatch(/^Filter comparand (is|is the) /); + expect(issue.message).toContain(names); + // The prescription is the accepted set, named. + expect(issue.message).toContain('A comparison value must be a string, number, bigint, boolean, null or Date.'); + expect(issue.message).not.toContain(' at where'); + }); + + it.each([ + ['$null: a plain object — the type face\'s refusal, not the flag rule\'s', { stage: { $null: { a: 1 } } }, 'stage.$null', /^Filter comparand is a plain object/], + ['$exists: undefined — the type face\'s refusal', { stage: { $exists: undefined } }, 'stage.$exists', /^Filter comparand is undefined\./], + ['$null: a bigint within 2^53 — the type face passes it, the flag rule refuses it', { stage: { $null: 5n } }, 'stage.$null', /^Operator "\$null" on field "stage" requires a boolean comparand/], + ['$icontains: a Map — the type face\'s refusal, and the #19514 text arm stays silent', { name: { $icontains: new Map() } }, 'name.$icontains', /^Filter comparand is a Map instance/], + ['$between: [undefined, 5] — the shape face\'s blank endpoint answers first', { amount: { $between: [undefined, 5] } }, 'amount.$between.0', /^A blank value is not a valid \$between endpoint/], + ] as const)('one slot, one issue, in the query doors\' order — %s', (_label, where, issuePath, head) => { + const result = FilterConditionSchema.safeParse(where); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual([issuePath]); + expect(issueAt(result, issuePath).message).toMatch(head); + }); + + it('every refused slot of one document is reported, each at its own path — the type face\'s among the shape face\'s', () => { + const result = FilterConditionSchema.safeParse({ + stage: { $eq: { a: 1 }, $in: 'won' }, + amount: { $gt: new Map(), $lt: null }, + owner: undefined, + $or: [{ tags: { $nin: ['x', { a: 1 }] } }], + }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.')).sort()).toEqual([ + '$or.0.tags.$nin.1', + 'amount.$gt', + 'amount.$lt', + 'owner', + 'stage.$eq', + 'stage.$in', + ]); + }); + + it.each(FILTER_COMPARAND_TYPE_CASES.map((row) => [row.name, row] as const))( + 'the type face\'s own conformance table — %s', + (_name, row) => { + const filter = row.filter(); + const result = FilterConditionSchema.safeParse(filter); + if (row.verdict === 'door-refusal') { + // Refused on save — and the rows are the query doors' refusals, in + // their envelope, so the save door is refusing what they refuse. + expect(result.success, show(filter)).toBe(false); + expect(faceRefusal(filter) ?? typeFaceRefusal(filter)).toMatchObject({ code: row.code, status: 400 }); + for (const issue of result.error!.issues) expect(issue.message).not.toContain(' at where'); + } else { + // Every accepted row is accepted AND kept as written — a bigint stays a + // bigint; the query face's narrowing is a query-time rewrite. + expect(result.success, show(result.error?.issues)).toBe(true); + expect(result.data).toEqual(filter); + } + }, + ); +}); + +// --------------------------------------------------------------------------- +// §7 Adopting the analytics carrier moves no published byte +// --------------------------------------------------------------------------- + +describe('#20116 §7 — the analytics carrier filter publishes exactly the bare condition', () => { + it('its JSON Schema projection is the optional FilterCondition\'s, byte for byte — the walk is a dropped refinement', () => { + // Why moving the dataset carriers' declaration into its own module, and + // making the dashboard widget's filter a carrier, changes no published + // body: the wrapper's refinement has no JSON form, so a carrier projects + // exactly as the plain slot it replaces (the build records it in + // `dropped-refinements.baseline.json` instead). + expect(JSON.stringify(z.toJSONSchema(analyticsCarrierFilter()))).toBe( + JSON.stringify(z.toJSONSchema(FilterConditionSchema.optional())), + ); + }); + + it('the widget filter keeps its published description and title', () => { + const widget = z.toJSONSchema(DashboardSchema) as { + properties?: { widgets?: { items?: { properties?: { filter?: { description?: string; title?: string } } } } }; + }; + const filter = widget.properties?.widgets?.items?.properties?.filter; + expect(filter?.description).toBe('Presentation-scope filter (runtimeFilter)'); + expect(filter?.title).toBe('Filter'); }); }); From 56285cb33a8781328d67353556cf37f0a01c5158 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:38:12 +0000 Subject: [PATCH 4/8] chore(spec): register the stage-2 narrowing, its dropped refinements and its HTTP-door pins - ADR-0087 semantic entry filter-comparand-types-and-widget-nested-slots-refused-at-save and the regenerated registry. - dropped-refinements.baseline.json: the widget filter's nested-relation walk is a dropped refinement at ui/DashboardWidget filter, ui/Dashboard widgets.element.filter and the four installed-package manifests. - The changeset (Clause-2: no (narrowing), BREAKING, registered). - rest: the analytics routes' schema door refuses the type face's JSON-representable cells, located on the member. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- ...6-filter-save-door-type-face-and-widget.md | 70 +++++++++++++++++ .../analytics-filter-refusal-envelope.test.ts | 24 +++++- .../spec/dropped-refinements.baseline.json | 8 +- ...and-widget-nested-slots-refused-at-save.ts | 77 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 73 ++++++++++++++++++ 5 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 .changeset/20116-filter-save-door-type-face-and-widget.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts diff --git a/.changeset/20116-filter-save-door-type-face-and-widget.md b/.changeset/20116-filter-save-door-type-face-and-widget.md new file mode 100644 index 00000000000..5e8dc7ac5e1 --- /dev/null +++ b/.changeset/20116-filter-save-door-type-face-and-widget.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and a dashboard widget filter judges its nested relations (#20116) + +**BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`. + +## What changes + +**The comparand-type face, asked on save.** `FilterConditionSchema` now refuses, at parse, every comparand the comparand-type face (`normalizeFilterComparandTypes`, the accepted set `string | number | bigint | boolean | null | Date`) refuses on every query: + +- a plain object where a single value belongs — `{ stage: { $eq: { a: 1 } } }`, and a `{ $field: … }` whose name is not a string; +- a `Map`, a class instance, a function or a Symbol; +- `undefined`, as `{ owner: undefined }` or under an operator; +- a bigint beyond ±2^53; + +as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation. + +**A dashboard widget filter is an analytics carrier.** `DashboardWidgetSchema.filter` now declares the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs it into the same analytics query. So the widget judges the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets..filter.acct.…`, as on the two dataset carriers. That declaration moved, verbatim, into its own module shared by the three carriers; the dataset carriers' published JSON Schema is byte-identical. + +Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when the widget is charted. + +**One slot, one issue.** A slot raises the first refusal the query doors give, in their order: the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. Where a face refuses a slot, the schema door's own `$icontains` and date-preset arms stay silent on it, so no slot carries two issues. + +**The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. + +`defineStack`, `os validate` and a save through the metadata protocol (`422 INVALID_METADATA`) refuse such a document at the slot's path. + +## What does NOT change + +- **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. +- **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written. +- **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one. +- **A report's `runtimeFilter` and a joined report block's `runtimeFilter` still save a refused shape INSIDE a nested relation.** They are charted through the same door; adopting the analytics-carrier filter there is one line per slot and is not in this change. +- **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. +- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new widget rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter` and `ui/Dashboard` `widgets.element.filter`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `{ stage: { $eq: { a: 1 } } }` | the one value you meant: `{ stage: { $eq: "won" } }` | +| `{ stage: { $in: [{ a: 1 }] } }` | a list of values: `{ stage: { $in: ["won", "lost"] } }` | +| `{ amount: { $gt: { $field: 5 } } }` | a column name: `{ amount: { $gt: { $field: "budget" } } }` | +| `{ owner: undefined }`, `{ owner: { $eq: undefined } }` | `{ owner: { $eq: null } }` ("has no value"), `{ owner: { $ne: null } }` ("has a value"), or omit the key | +| `{ tags: new Map(…) }`, a class instance | the value itself, as a string, number, boolean, `null` or `Date` | +| `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string | +| a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` | +| a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` | + +### FROM → TO at the HTTP doors + +Same status, different code: the refusal now comes from the route's schema door, located on the member, instead of from the analytics filter normalizer. + +| request | before | after | +|:--|:--|:--| +| `POST /analytics/dataset/query` with `selection.runtimeFilter: { stage: { $eq: { a: 1 } } }` | `400 INVALID_FILTER` from the analytics normalizer, in the comparand-type face's sentence | `400 VALIDATION_FAILED`, `details.fields[]` entry `selection.runtimeFilter.stage.$eq` with the sentence `Filter comparand is a plain object ({"a":1}), which no driver can compare. …` | +| the same route with `selection.runtimeFilter: { stage: { $in: ["won", { a: 1 }] } }` | `400 INVALID_FILTER` | `400 VALIDATION_FAILED` at `selection.runtimeFilter.stage.$in.1` | +| `POST /analytics/query` (`AnalyticsQueryRequestSchema`) with either shape in `where` | `400 INVALID_FILTER` | refused by the request schema at `where.stage.$eq` / `where.stage.$in.1`, answered `400 VALIDATION_FAILED` | + +A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FAILED` instead. Both are 400 and both name the field. The other refused values cannot arrive over HTTP: JSON has no `Map`, `undefined` or bigint. + +## Who is affected, measured + +A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no filter whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. + +Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter judges the nested-relation slots the analytics door already refused on chart. + + diff --git a/packages/rest/src/analytics-filter-refusal-envelope.test.ts b/packages/rest/src/analytics-filter-refusal-envelope.test.ts index d2a139b4d6e..83687c0501c 100644 --- a/packages/rest/src/analytics-filter-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-filter-refusal-envelope.test.ts @@ -220,7 +220,10 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the * earlier — at the route's schema door — and the block above no longer claims * them. [#20116] A fourth joined them: the one-bound `$between`, once * `FilterConditionSchema` began refusing on save every comparand slot the - * shared comparand-shape face refuses on query. + * shared comparand-shape face refuses on query. [#20116, stage 2] Then the + * comparand-TYPE face's JSON-representable cells — a plain object where one + * value belongs, as the comparand or as a list member — once that schema asked + * the type face too. * * ⚠️ This is a CODE change on a live wire surface, so it is recorded with the * measurement that justifies it rather than as a test edit. Since #17551 the @@ -236,6 +239,8 @@ describe('[#5352] POST /analytics/dataset/query — a filter refusal reaches the * | `{ $not: 5 }` | refused at the schema | refused at the schema | * | `{ stage: {} }` | passes the schema | passes the schema | * | `{ amount: { $between: [10] } }` | refused at the schema (#20116) | refused at the schema (#20116) | + * | `{ stage: { $eq: { a: 1 } } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) | + * | `{ stage: { $in: ['won', { a: 1 }] } }` | refused at the schema (#20116 stage 2) | refused at the schema (#20116 stage 2) | * | `{ $nor: [{…}] }` | passes the schema | passes the schema | * | `{ $or: [] }` | passes the schema | passes the schema | * @@ -270,6 +275,23 @@ describe('[#17551] the structurally-malformed filter spellings are refused at th member: 'selection.runtimeFilter.amount.$between', sentence: /^Operator "\$between" on field "amount" requires a \[min, max\] value array\. Received array \(\[10\]\)\. A range needs exactly two bounds/, }, + { + // [#20116, stage 2] Before, this crossed the schema and the normalizer + // refused it `INVALID_FILTER` / 400 in the comparand-TYPE face's words + // (`at where.stage.$eq`). The schema door now asks that face on save and + // prints the same sentence less its location, located on the member. + name: 'a plain object where a single value belongs', + runtimeFilter: { stage: { $eq: { a: 1 } } }, + member: 'selection.runtimeFilter.stage.$eq', + sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\. A comparison value must be a string, number, bigint, boolean, null or Date\./, + }, + { + // [#20116, stage 2] The same face, on a list member: located on the member. + name: 'a plain object as an $in member', + runtimeFilter: { stage: { $in: ['won', { a: 1 }] } }, + member: 'selection.runtimeFilter.stage.$in.1', + sentence: /^Filter comparand is a plain object \(\{"a":1\}\), which no driver can compare\./, + }, ]; for (const c of AT_THE_DOOR) { diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 43a2841f631..ca64d81a206 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 211, - "droppedRefinementSites": 604, + "droppedRefinementSites": 610, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -55,6 +55,7 @@ "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.widgets.element", + "manifest.dashboards.element.widgets.element.filter", "manifest.datasets.element", "manifest.datasets.element.filter", "manifest.datasets.element.include.element", @@ -156,6 +157,7 @@ "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.widgets.element", + "data.options[1].manifest.dashboards.element.widgets.element.filter", "data.options[1].manifest.datasets.element", "data.options[1].manifest.datasets.element.filter", "data.options[1].manifest.datasets.element.include.element", @@ -241,6 +243,7 @@ "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.widgets.element", + "options[1].manifest.dashboards.element.widgets.element.filter", "options[1].manifest.datasets.element", "options[1].manifest.datasets.element.filter", "options[1].manifest.datasets.element.include.element", @@ -287,6 +290,7 @@ "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", + "data.packages.element.options[1].manifest.dashboards.element.widgets.element.filter", "data.packages.element.options[1].manifest.datasets.element", "data.packages.element.options[1].manifest.datasets.element.filter", "data.packages.element.options[1].manifest.datasets.element.include.element", @@ -1162,12 +1166,14 @@ "sites": [ "globalFilters.element", "widgets.element", + "widgets.element.filter", "widgets.element.filter.lazy" ] }, "ui/DashboardWidget": { "sites": [ "", + "filter", "filter.lazy" ] }, diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts new file mode 100644 index 00000000000..dd5db8ef5b3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The second half of the schema door's parity with the query faces. The first +// (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face +// and the flag rule; this one asks the comparand-TYPE face too, and makes a +// dashboard widget filter an analytics carrier, so the slots inside a nested +// relation that the analytics where door judges are judged on save there as on +// a dataset filter. The faces stay the judges; the save door only asks them. +export const entry: SemanticMigration = { + id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — a comparand the comparand-type face refuses, now refused when the ' + + 'document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, ' + + 'text or flag comparand such as { a: 1 }, including a { $field } whose name is not a ' + + 'string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond ' + + 'plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand ' + + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + + 'at the reach the save door already had (the field entries of a condition and of every ' + + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' + + 'dashboard widget filter, INSIDE a nested-relation condition as well, for these shapes and ' + + 'for every shape the earlier entry names — so ui.DashboardWidget.filter gains the ' + + 'nested-relation reach of the two dataset carriers', + replacement: + 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' + + 'string or within range. Inside a nested relation on a widget filter, write the same ' + + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + + 'within 2^53 are untouched, and the save door keeps a bigint as written', + reason: + 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' + + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' + + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' + + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' + + 'dashboard widget filter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and ' + + 'for a list in a nested equality slot, which the analytics where door refuses when the ' + + 'widget is charted, because only the two dataset carriers had the nested-relation walk. ' + + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' + + 'own words less its location clause. The widget filter declares the same analytics-carrier ' + + 'filter as the dataset carriers, so its nested-relation slots are judged by the same walk. ' + + '⚠️ One position still refuses only at execution: a refused shape INSIDE a nested-relation ' + + 'condition on a report runtimeFilter or a joined report block runtimeFilter, which reach ' + + 'the analytics where door too but carry the shared schema\'s reach only; each adopts the ' + + 'carrier in one line. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' + + 'defineStack or os validate is refused at the filter\'s path. A JSON document can carry ' + + 'only the plain-object cells; the others arrive only from TypeScript authoring. Such a ' + + 'filter has failed every query since the type face\'s ruling, so the refusal is a repair ' + + 'and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every stored document that carries a filter: os validate ' + + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' + + 'path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type ' + + 'face\'s sentence and the accepted set. A producer census before the change — a literal ' + + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + + 'filter in the example stacks — found no authored filter carrying one of these values and ' + + 'no dashboard widget filter with a nested-relation condition. ⛔ A clean save is NOT a ' + + 'complete sweep for the one position the reason names: search report runtimeFilters for a ' + + 'nested-relation condition whose inner field carries one of these shapes, and chart it, ' + + 'where the analytics where door refuses with INVALID_FILTER / 400 naming the path.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 59304d18f0f..db358e1a404 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9307,6 +9307,79 @@ const step18: MigrationStep = { + 'authoring schema door (SET_MEMBER_DESCRIPTION); they are outside THIS entry\'s transition ' + 'and are worth sweeping in the same pass.', }, + // The second half of the schema door's parity with the query faces. The first + // (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face + // and the flag rule; this one asks the comparand-TYPE face too, and makes a + // dashboard widget filter an analytics carrier, so the slots inside a nested + // relation that the analytics where door judges are judged on save there as on + // a dataset filter. The faces stay the judges; the save door only asks them. + { + id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.FilterCondition — a comparand the comparand-type face refuses, now refused when the ' + + 'document is PARSED: a plain object where a single value belongs (an $eq, $ne, ordering, ' + + 'text or flag comparand such as { a: 1 }, including a { $field } whose name is not a ' + + 'string), a Map, a class instance, a function, a Symbol, undefined, or a bigint beyond ' + + 'plus or minus 2^53, whether it is the comparand itself, an implicit-equality comparand ' + + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + + 'at the reach the save door already had (the field entries of a condition and of every ' + + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' + + 'dashboard widget filter, INSIDE a nested-relation condition as well, for these shapes and ' + + 'for every shape the earlier entry names — so ui.DashboardWidget.filter gains the ' + + 'nested-relation reach of the two dataset carriers', + replacement: + 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' + + 'string or within range. Inside a nested relation on a widget filter, write the same ' + + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + + 'within 2^53 are untouched, and the save door keeps a bigint as written', + reason: + 'The save door narrows to exactly what the query faces already refuse (#20116, stage 2 of ' + + 'the collector). The comparand-type face (normalizeFilterComparandTypes, the #7872 ' + + 'ruling\'s accepted set) refuses these values on every query: parseFilterAST, the engine ' + + 'seam, the analytics where door and the read-scope compiler all run it. Measured on ' + + 'origin/main 17bd3187 before the change: FilterConditionSchema, a dataset filter, a ' + + 'dataset measure filter, a dashboard widget filter, a report runtimeFilter and a joined ' + + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' + + 'dashboard widget filter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and ' + + 'for a list in a nested equality slot, which the analytics where door refuses when the ' + + 'widget is charted, because only the two dataset carriers had the nested-relation walk. ' + + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' + + 'own words less its location clause. The widget filter declares the same analytics-carrier ' + + 'filter as the dataset carriers, so its nested-relation slots are judged by the same walk. ' + + '⚠️ One position still refuses only at execution: a refused shape INSIDE a nested-relation ' + + 'condition on a report runtimeFilter or a joined report block runtimeFilter, which reach ' + + 'the analytics where door too but carry the shared schema\'s reach only; each adopts the ' + + 'carrier in one line. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' + + 'defineStack or os validate is refused at the filter\'s path. A JSON document can carry ' + + 'only the plain-object cells; the others arrive only from TypeScript authoring. Such a ' + + 'filter has failed every query since the type face\'s ruling, so the refusal is a repair ' + + 'and not a loss. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every stored document that carries a filter: os validate ' + + 'or defineStack, and a save through the metadata protocol, report each refused slot by ' + + 'path, for example widgets.0.filter.acct.stage.$in.1 or filter.stage.$eq, with the type ' + + 'face\'s sentence and the accepted set. A producer census before the change — a literal ' + + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + + 'filter in the example stacks — found no authored filter carrying one of these values and ' + + 'no dashboard widget filter with a nested-relation condition. ⛔ A clean save is NOT a ' + + 'complete sweep for the one position the reason names: search report runtimeFilters for a ' + + 'nested-relation condition whose inner field carries one of these shapes, and chart it, ' + + 'where the analytics where door refuses with INVALID_FILTER / 400 naming the path.', + }, // The RUNTIME door's half of the question the sibling entry // view-filter-rule-scalar-operator-array-refused answered at the view-rule // schema door: that entry refuses an array on a scalar view operator when the From 54b99f3ceae4bddcf9eb67edd8dd19431c74349f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:54:25 +0000 Subject: [PATCH 5/8] fix(spec): narrow the walks' field value with the type predicate, so the DTS build types it `isDataObject` / `isAnalyticsDataObject` take an object and sit behind the existing plain-node predicate, so the operator-map branch keeps its `Record` narrowing. No verdict moves. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- packages/spec/src/data/filter.zod.ts | 19 ++++++++-------- .../spec/src/ui/analytics-carrier-filter.ts | 22 +++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 78ec1e0ebaf..c27bbb374ee 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1703,16 +1703,15 @@ function isPlainFilterNode(value: unknown): value is Record { } /** - * [#20116] Is this field value a COMPARAND, as the comparand-type face - * classifies it? Everything but filter structure is — and structure is a PLAIN - * object only, prototype `Object.prototype` or `null` (the face's `isFilterNode`, - * the convention `driver-sql` shares since #5134, and the analytics door's - * nested-relation test). A `Map`, a class instance or a `Date` answers + * [#20116] Is this object DATA rather than filter structure, as the + * comparand-type face classifies it? Structure is a PLAIN object only, + * prototype `Object.prototype` or `null` (the face's `isFilterNode`, the + * convention `driver-sql` shares since #5134, and the analytics door's + * nested-relation test). A `Map` or a class instance answers * `typeof x === 'object'` while being data: `{ stage: new Map() }` is a * comparand the type face refuses, never an empty nested relation. */ -function isFieldValueComparand(value: unknown): boolean { - if (!isPlainFilterNode(value)) return true; +function isDataObject(value: object): boolean { const proto = Object.getPrototypeOf(value); return proto !== Object.prototype && proto !== null; } @@ -1759,8 +1758,8 @@ function isFieldValueComparand(value: unknown): boolean { * a `{ $field }` reference, a `{placeholder}` string resolved at request time * and a bigint within ±2^53 keep passing, because the face passes them. So * that a `Map` or a class instance reaches the face at all, a field value is - * a comparand unless it is a PLAIN object ({@link isFieldValueComparand}, - * the face's own structure test). + * a comparand unless it is a PLAIN object ({@link isDataObject}, the face's + * own structure test). * - **The words** are chosen in that module: the face's own sentence where * the two doors already share a builder, the enforced * operator slot's sentence where `FieldOperatorsSchema` already prints one for @@ -1863,7 +1862,7 @@ function checkFilterConditionComparands( // the face's exact reach: its walk never descends a field spec that has no // `$` key, so nothing inside a nested-relation condition is refused there, // and nothing is refused here. See the docblock. - if (isFieldValueComparand(value)) { + if (!isPlainFilterNode(value) || isDataObject(value)) { if (depth === 0) reportQueryFaceRefusals(ctx, [...path, key], key, undefined, value, FieldOperatorsSchema); continue; } diff --git a/packages/spec/src/ui/analytics-carrier-filter.ts b/packages/spec/src/ui/analytics-carrier-filter.ts index 9c461fda4d0..be786812048 100644 --- a/packages/spec/src/ui/analytics-carrier-filter.ts +++ b/packages/spec/src/ui/analytics-carrier-filter.ts @@ -45,16 +45,16 @@ function isAnalyticsNestedRelationSpec(spec: unknown): spec is Record Date: Sun, 27 Sep 2026 20:16:16 +0000 Subject: [PATCH 6/8] fix(spec)!: a report's and a joined block's runtimeFilter are analytics carriers ReportSchema.runtimeFilter and JoinedReportBlockSchema.runtimeFilter declare analyticsCarrierFilter(), so a comparand the analytics where door refuses INSIDE a nested relation is refused on save there too, as on the dataset and widget carriers. The parity pin's two EXPECTED_OPEN rows move into CARRIERS; the changeset and the semantic entry widen to the five carriers and drop the one-open-position warning; the one-issue-per-slot dedupe is named. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- ...6-filter-save-door-type-face-and-widget.md | 16 +++--- .../data/filter-save-door-face-parity.test.ts | 53 ++++++++++--------- .../src/data/filter-save-door-refusals.ts | 5 +- ...and-widget-nested-slots-refused-at-save.ts | 43 ++++++++------- packages/spec/src/migrations/registry.ts | 43 ++++++++------- .../spec/src/ui/analytics-carrier-filter.ts | 25 ++++----- packages/spec/src/ui/report.zod.ts | 20 +++++-- 7 files changed, 113 insertions(+), 92 deletions(-) diff --git a/.changeset/20116-filter-save-door-type-face-and-widget.md b/.changeset/20116-filter-save-door-type-face-and-widget.md index 5e8dc7ac5e1..c56cc1405ef 100644 --- a/.changeset/20116-filter-save-door-type-face-and-widget.md +++ b/.changeset/20116-filter-save-door-type-face-and-widget.md @@ -2,7 +2,7 @@ "@objectstack/spec": minor --- -fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and a dashboard widget filter judges its nested relations (#20116) +fix(spec)!: a filter carrying a comparand the comparand-type face refuses is refused when it is saved, and every charted presentation filter judges its nested relations (#20116) **BREAKING** — an accept-set narrowing of published authoring schemas, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The save door narrows to exactly what the query faces already refuse; this is stage 2 of the change whose first stage registered `filter-query-face-comparands-refused-at-save`. The hand-migration prescription is registered under protocol major 18 as `filter-comparand-types-and-widget-nested-slots-refused-at-save`. @@ -17,11 +17,11 @@ fix(spec)!: a filter carrying a comparand the comparand-type face refuses is ref as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$nin` / `$between` list member (`{ stage: { $in: [{ a: 1 }] } }`). The face is called read-only as the judge, after the comparand-shape face, so the save door refuses exactly what it refuses and passes what it passes. A field value that is not a PLAIN object (a `Map`, a class instance) is the comparand the face calls it, never an empty nested relation. -**A dashboard widget filter is an analytics carrier.** `DashboardWidgetSchema.filter` now declares the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs it into the same analytics query. So the widget judges the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets..filter.acct.…`, as on the two dataset carriers. That declaration moved, verbatim, into its own module shared by the three carriers; the dataset carriers' published JSON Schema is byte-identical. +**Every charted presentation filter is an analytics carrier.** `DashboardWidgetSchema.filter`, `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` now declare the same filter as `DatasetSchema.filter` and `DatasetMeasureSchema.filter`, because the dataset executor ANDs each into the same analytics query. So they judge the slots INSIDE a nested-relation condition the way the analytics `where` door does: `{ acct: { stage: { $in: ["won", null] } } }`, `{ acct: { region: ["a"] } }` and `{ acct: { region: { $eq: ["a"] } } }` are refused on save at `widgets.0.filter.acct.…`, `runtimeFilter.acct.…` and `blocks.0.runtimeFilter.acct.…`, as on the two dataset carriers — every shape the earlier stage refuses at the top level, and every type-face value above. That declaration moved, verbatim, into its own module shared by the five carriers; every carrier's published JSON Schema body is byte-identical. -Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when the widget is charted. +Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted. -**One slot, one issue.** A slot raises the first refusal the query doors give, in their order: the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. Where a face refuses a slot, the schema door's own `$icontains` and date-preset arms stay silent on it, so no slot carries two issues. +**One slot, one issue — a dedupe; no verdict moves.** A slot raises the first refusal the query doors give, in their order: the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. Where a face refuses a slot, the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land for one defect: for example `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. Every document refused before is still refused, and every document accepted before is still accepted — only the second issue on an already-refused slot is gone. **The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. @@ -32,9 +32,8 @@ Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, - **Nothing stored is rewritten, and nothing is dropped.** The parse fails and strips nothing. The read path does not re-validate stored rows, so a stored document keeps loading, and its next save is refused. - **What the face passes still passes:** a `Date`, a `{ $field: "column" }` reference, a `{placeholder}` string the engine resolves at request time (`{current_user_id}`, `{today}`), and a bigint within ±2^53. The face narrows such a bigint to its number on a query; the save door keeps it as written. - **The shared reach is unchanged.** On every carrier but the three analytics ones, a field spec with no `$` key (a nested-relation condition) is not judged, because no face descends one. -- **A report's `runtimeFilter` and a joined report block's `runtimeFilter` still save a refused shape INSIDE a nested relation.** They are charted through the same door; adopting the analytics-carrier filter there is one line per slot and is not in this change. - **The data-engine calls' `where` option still parses.** Its type is a union whose first arm is an open record. The face refuses the shape when the call runs. -- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new widget rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter` and `ui/Dashboard` `widgets.element.filter`. +- **No key, export or JSON Schema body changes.** The published JSON Schema cannot state a refinement; the new nested-relation rule is recorded as a dropped refinement at `ui/DashboardWidget` `filter`, `ui/Dashboard` `widgets.element.filter`, `ui/Report` `runtimeFilter` and the joined block's `runtimeFilter`, and at the same positions inside the installed-package manifests. ## FROM → TO @@ -48,6 +47,7 @@ Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, | `{ qty: { $gt: 2n ** 60n } }` | a bound within ±2^53, or the value compared as a string | | a widget `filter: { acct: { stage: { $in: ["won", null] } } }` | `{ $or: [{ acct: { stage: { $in: ["won"] } } }, { acct: { stage: { $null: true } } }] }` | | a widget `filter: { acct: { region: ["a", "b"] } }` | `{ acct: { region: { $in: ["a", "b"] } } }` | +| a report or joined-block `runtimeFilter` with either nested shape above | the same rewrite, at `runtimeFilter` / `blocks..runtimeFilter` | ### FROM → TO at the HTTP doors @@ -63,8 +63,8 @@ A client that branches on `INVALID_FILTER` for these shapes reads `VALIDATION_FA ## Who is affected, measured -A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no filter whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. +A literal scan of every shape, with a lit control per shape, over `examples/**` and the non-test `packages/**` of this repository, the console repository at its pinned commit `f8a9d0fb05` and the cloud repository's `main` at `96eb092fbf`, found no authored filter carrying a plain object where a value belongs, a `Map` or class instance, `undefined` or a bigint beyond 2^53 — every hit was prose, a driver's operator switch, or a conformance table — and no `filter` / `runtimeFilter` / `where` / `relatedListFilter` whose first entry is a nested relation holding a list or an operator map. A runtime walk of every filter in the example stacks (`app-crm`, `app-todo`, `app-multi-package`, and `app-showcase`'s metadata modules) compared the old and new doors on each and found none refused by the new one alone. Deployed datasets, dashboards and reports were NOT measured. Validating each stack, or re-saving each document, finds every instance the surface above lists. -Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter judges the nested-relation slots the analytics door already refused on chart. +Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged. Comparand values that every query face already refused are now refused on save as well, and a dashboard widget filter and both report runtimeFilters judge the nested-relation slots the analytics door already refused on chart. diff --git a/packages/spec/src/data/filter-save-door-face-parity.test.ts b/packages/spec/src/data/filter-save-door-face-parity.test.ts index 3dd4e06f99b..fd96f89a495 100644 --- a/packages/spec/src/data/filter-save-door-face-parity.test.ts +++ b/packages/spec/src/data/filter-save-door-face-parity.test.ts @@ -5,17 +5,19 @@ * slots the QUERY faces refuse — at the top level and in every `$and` / `$or` / * `$not` member, and not inside a nested-relation condition, which the face * never descends. The analytics carriers (a dataset `filter`, a measure - * `filter` and, since stage 2, a dashboard widget `filter`), charted through - * the analytics `where` door that DOES descend a relation, refuse the same - * slots inside one (§5), asking the same function. + * `filter` and, since stage 2, a dashboard widget `filter` and a report's and a + * joined report block's `runtimeFilter`), charted through the analytics + * `where` door that DOES descend a relation, refuse the same slots inside one + * (§5), asking the same function. * * Stage 2 adds the comparand-TYPE face (`normalizeFilterComparandTypes`) to * "the query faces": measured on `origin/main` `17bd3187`, every save door * accepted `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a * `Map` comparand, top level and nested, while that face and the analytics * door refused each with `INVALID_FILTER` / 400; and a dashboard widget - * `filter` accepted `{ acct: { stage: { $in: ['won', null] } } }` and #20080's - * nested equality lists, which the analytics door refuses on chart. §1 and §5 + * `filter`, a report `runtimeFilter` and a joined block `runtimeFilter` + * accepted `{ acct: { stage: { $in: ['won', null] } } }` and #20080's nested + * equality lists, which the analytics door refuses on chart. §1 and §5 * ask all three rules; §6 pins the type face's words and table; §7 the * carriers. * @@ -537,7 +539,8 @@ describe('#20116 §5 — inside a nested relation, the analytics carriers refuse // analytics carriers' own walk (`refuseNestedRelationComparands`, #20207's, // `ui/analytics-carrier-filter.ts` since stage 2) reaches those entries and // asks the same function the shared walk asks, so the table is §1's, one - // relation down, on every carrier. + // relation down, on every carrier — the two dataset carriers, the dashboard + // widget's `filter`, and a report's and a joined block's `runtimeFilter`. const dataset = (filter: unknown) => ({ name: 'deals_ds', label: 'Deals', @@ -552,22 +555,16 @@ describe('#20116 §5 — inside a nested relation, the analytics carriers refuse widgets: [{ id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'], filter }], }); - /** Every analytics carrier: its parse, and where its filter sits in the document. */ + /** + * Every analytics carrier — every stored filter the analytics door charts: + * its parse, and where its filter sits in the document. The two report rows + * were EXPECTED-OPEN until the report half of the collector folded in (the + * carrier on `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter`). + */ const CARRIERS: ReadonlyArray Parsed, at: string]> = [ ['dataset filter', (filter) => DatasetSchema.safeParse(dataset(filter)), 'filter'], ['measure filter', (filter) => DatasetMeasureSchema.safeParse({ name: 'deal_count', aggregate: 'count', filter }), 'filter'], ['dashboard widget filter', (filter) => DashboardSchema.safeParse(dashboard(filter)), 'widgets.0.filter'], - ]; - - /** - * EXPECTED-OPEN — charted through the same analytics door, and NOT carriers - * yet: a report's `runtimeFilter` and a joined report block's. That half of - * the collector stays on #20116 (its regions are held by #20161's PR). The - * flip is one line per slot in `ui/report.zod.ts` — - * `runtimeFilter: analyticsCarrierFilter()…` — after which these rows go red - * and move into `CARRIERS` above. - */ - const EXPECTED_OPEN: ReadonlyArray Parsed, at: string]> = [ ['report runtimeFilter', (runtimeFilter) => ReportSchema.safeParse({ name: 'pipeline', label: 'Pipeline', type: 'summary', dataset: 'sales', rows: ['stage'], values: ['revenue'], runtimeFilter, }), 'runtimeFilter'], @@ -636,13 +633,14 @@ describe('#20116 §5 — inside a nested relation, the analytics carriers refuse expect(refused).toBeGreaterThanOrEqual(15); }); - it('EXPECTED-OPEN (#20116 remainder, #20161\'s regions) — the report carriers still save what the analytics door refuses inside a relation', () => { - for (const [filter] of NESTED_MEMBERS) { - for (const [carrier, parse] of EXPECTED_OPEN) { - const parsed = parse(filter); - expect(parsed.success, `${carrier} ${show(filter)} — flip this row: see EXPECTED_OPEN`).toBe(true); - } - } + it('every carrier the analytics door charts is on the list — no stored presentation filter keeps the shared reach alone', () => { + // The five stored filters `dataset-executor.ts` hands to the analytics + // `where` door: the dataset's own and its measures', and the presentation + // scopes it ANDs in as `runtimeFilter` (a widget's `filter`, a report's and + // a joined block's `runtimeFilter`). + expect(CARRIERS.map(([carrier]) => carrier)).toEqual([ + 'dataset filter', 'measure filter', 'dashboard widget filter', 'report runtimeFilter', 'joined report block runtimeFilter', + ]); }); it.each(NESTED_MEMBERS.map(([filter, slot]) => [show(filter), filter, slot] as const))( @@ -801,4 +799,9 @@ describe('#20116 §7 — the analytics carrier filter publishes exactly the bare expect(filter?.description).toBe('Presentation-scope filter (runtimeFilter)'); expect(filter?.title).toBe('Filter'); }); + + it('the report runtimeFilter keeps its published description', () => { + const report = z.toJSONSchema(ReportSchema) as { properties?: { runtimeFilter?: { description?: string } } }; + expect(report.properties?.runtimeFilter?.description).toBe('Render-time scope filter'); + }); }); diff --git a/packages/spec/src/data/filter-save-door-refusals.ts b/packages/spec/src/data/filter-save-door-refusals.ts index 4dceb3156c9..4ee504ff5bf 100644 --- a/packages/spec/src/data/filter-save-door-refusals.ts +++ b/packages/spec/src/data/filter-save-door-refusals.ts @@ -13,8 +13,9 @@ * (`refuseNestedRelationComparands`, `../ui/analytics-carrier-filter.ts`), * over the entries INSIDE a nested-relation condition, which the analytics * `where` door flattens to dotted members and judges like any other entry. - * Every filter charted through that door declares it: a dataset `filter`, a - * measure `filter` and a dashboard widget `filter`. + * Every stored filter charted through that door declares it: a dataset + * `filter`, a measure `filter`, a dashboard widget `filter`, and a report's + * and a joined report block's `runtimeFilter`. * * So a slot is judged one way whichever reach finds it, and a rule added here * reaches both. diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts index dd5db8ef5b3..79f8e7ff2af 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts @@ -5,9 +5,10 @@ import type { SemanticMigration } from '../../types.js'; // The second half of the schema door's parity with the query faces. The first // (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face // and the flag rule; this one asks the comparand-TYPE face too, and makes a -// dashboard widget filter an analytics carrier, so the slots inside a nested -// relation that the analytics where door judges are judged on save there as on -// a dataset filter. The faces stay the judges; the save door only asks them. +// dashboard widget filter and both report runtimeFilters analytics carriers, so +// the slots inside a nested relation that the analytics where door judges are +// judged on save there as on a dataset filter. The faces stay the judges; the +// save door only asks them. export const entry: SemanticMigration = { id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -21,15 +22,18 @@ export const entry: SemanticMigration = { + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + 'at the reach the save door already had (the field entries of a condition and of every ' + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' - + 'dashboard widget filter, INSIDE a nested-relation condition as well, for these shapes and ' - + 'for every shape the earlier entry names — so ui.DashboardWidget.filter gains the ' - + 'nested-relation reach of the two dataset carriers', + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, ' + + 'INSIDE a nested-relation condition as well, for these shapes and for every shape the ' + + 'earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ' + + 'ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset ' + + 'carriers', replacement: 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' - + 'string or within range. Inside a nested relation on a widget filter, write the same ' + + 'string or within range. Inside a nested relation on a widget filter or a report ' + + 'runtimeFilter, write the same ' + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + 'within 2^53 are untouched, and the save door keeps a bigint as written', @@ -43,18 +47,19 @@ export const entry: SemanticMigration = { + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' - + 'dashboard widget filter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and ' - + 'for a list in a nested equality slot, which the analytics where door refuses when the ' - + 'widget is charted, because only the two dataset carriers had the nested-relation walk. ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter ' + + 'parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested ' + + 'equality slot, which the analytics where door refuses when they are charted, because ' + + 'only the two dataset carriers had the nested-relation walk. ' + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' - + 'own words less its location clause. The widget filter declares the same analytics-carrier ' - + 'filter as the dataset carriers, so its nested-relation slots are judged by the same walk. ' - + '⚠️ One position still refuses only at execution: a refused shape INSIDE a nested-relation ' - + 'condition on a report runtimeFilter or a joined report block runtimeFilter, which reach ' - + 'the analytics where door too but carry the shared schema\'s reach only; each adopts the ' - + 'carrier in one line. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'own words less its location clause; a second issue on a slot a face already refused (the ' + + 'schema door\'s own $icontains and date-preset arms) is no longer raised, which moves no ' + + 'verdict. The widget filter and both report runtimeFilters declare the same ' + + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' @@ -70,8 +75,6 @@ export const entry: SemanticMigration = { + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + 'filter in the example stacks — found no authored filter carrying one of these values and ' - + 'no dashboard widget filter with a nested-relation condition. ⛔ A clean save is NOT a ' - + 'complete sweep for the one position the reason names: search report runtimeFilters for a ' - + 'nested-relation condition whose inner field carries one of these shapes, and chart it, ' - + 'where the analytics where door refuses with INVALID_FILTER / 400 naming the path.', + + 'no widget filter or report runtimeFilter with a nested-relation condition holding a ' + + 'list or an operator map.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 1a5b344f678..7906d88672e 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9340,9 +9340,10 @@ const step18: MigrationStep = { // The second half of the schema door's parity with the query faces. The first // (filter-query-face-comparands-refused-at-save) asked the comparand-SHAPE face // and the flag rule; this one asks the comparand-TYPE face too, and makes a - // dashboard widget filter an analytics carrier, so the slots inside a nested - // relation that the analytics where door judges are judged on save there as on - // a dataset filter. The faces stay the judges; the save door only asks them. + // dashboard widget filter and both report runtimeFilters analytics carriers, so + // the slots inside a nested relation that the analytics where door judges are + // judged on save there as on a dataset filter. The faces stay the judges; the + // save door only asks them. { id: 'filter-comparand-types-and-widget-nested-slots-refused-at-save', // No backticks in `surface` — build-upgrade-guide renders it inside a code @@ -9356,15 +9357,18 @@ const step18: MigrationStep = { + 'or an $in / $nin / $between list member. On every schema that carries a FilterCondition, ' + 'at the reach the save door already had (the field entries of a condition and of every ' + '$and / $or / $not member); and, on a dataset filter, a dataset measure filter and now a ' - + 'dashboard widget filter, INSIDE a nested-relation condition as well, for these shapes and ' - + 'for every shape the earlier entry names — so ui.DashboardWidget.filter gains the ' - + 'nested-relation reach of the two dataset carriers', + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter, ' + + 'INSIDE a nested-relation condition as well, for these shapes and for every shape the ' + + 'earlier entry names — so ui.DashboardWidget.filter, ui.Report.runtimeFilter and ' + + 'ui.JoinedReportBlock.runtimeFilter gain the nested-relation reach of the two dataset ' + + 'carriers', replacement: 'a value of one of the six accepted comparand types — a string, number, bigint within plus ' + 'or minus 2^53, boolean, null or Date — or a { $field: "column" } reference where a column ' + 'is meant. A value set belongs in $in; an absent value is the null predicate ($eq null / ' + '$ne null) or an omitted key, never undefined; a bigint beyond 2^53 is compared as a ' - + 'string or within range. Inside a nested relation on a widget filter, write the same ' + + 'string or within range. Inside a nested relation on a widget filter or a report ' + + 'runtimeFilter, write the same ' + 'spelling the top-level refusal prescribes. A Date, a { $field } reference, a {placeholder} ' + 'string resolved at request time (such as {current_user_id} or {today}) and a bigint ' + 'within 2^53 are untouched, and the save door keeps a bigint as written', @@ -9378,18 +9382,19 @@ const step18: MigrationStep = { + 'report block runtimeFilter each parsed GREEN for { stage: { $eq: { a: 1 } } }, ' + '{ stage: { $in: [{ a: 1 }] } } and a Map comparand, top level and nested, while the type ' + 'face and the analytics where door refused each with INVALID_FILTER / 400. And a ' - + 'dashboard widget filter parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and ' - + 'for a list in a nested equality slot, which the analytics where door refuses when the ' - + 'widget is charted, because only the two dataset carriers had the nested-relation walk. ' + + 'dashboard widget filter, a report runtimeFilter and a joined report block runtimeFilter ' + + 'parsed GREEN for { acct: { stage: { $in: ["won", null] } } } and for a list in a nested ' + + 'equality slot, which the analytics where door refuses when they are charted, because ' + + 'only the two dataset carriers had the nested-relation walk. ' + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' - + 'own words less its location clause. The widget filter declares the same analytics-carrier ' - + 'filter as the dataset carriers, so its nested-relation slots are judged by the same walk. ' - + '⚠️ One position still refuses only at execution: a refused shape INSIDE a nested-relation ' - + 'condition on a report runtimeFilter or a joined report block runtimeFilter, which reach ' - + 'the analytics where door too but carry the shared schema\'s reach only; each adopts the ' - + 'carrier in one line. Metadata AT REST is not rewritten and this entry adds no D2 ' + + 'own words less its location clause; a second issue on a slot a face already refused (the ' + + 'schema door\'s own $icontains and date-preset arms) is no longer raised, which moves no ' + + 'verdict. The widget filter and both report runtimeFilters declare the same ' + + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' + 'conversion: none of these values has a single honest meaning as a comparand, which is why ' + 'each was refused. The read path does not re-validate stored rows, so a stored document ' + 'keeps loading; re-saving it through the metadata protocol (422 INVALID_METADATA), ' @@ -9405,10 +9410,8 @@ const step18: MigrationStep = { + 'scan with a lit control per shape over examples, the non-test packages of this repository, ' + 'the console repository at its pin and the cloud repository, plus a runtime walk of every ' + 'filter in the example stacks — found no authored filter carrying one of these values and ' - + 'no dashboard widget filter with a nested-relation condition. ⛔ A clean save is NOT a ' - + 'complete sweep for the one position the reason names: search report runtimeFilters for a ' - + 'nested-relation condition whose inner field carries one of these shapes, and chart it, ' - + 'where the analytics where door refuses with INVALID_FILTER / 400 naming the path.', + + 'no widget filter or report runtimeFilter with a nested-relation condition holding a ' + + 'list or an operator map.', }, // The RUNTIME door's half of the question the sibling entry // view-filter-rule-scalar-operator-array-refused answered at the view-rule diff --git a/packages/spec/src/ui/analytics-carrier-filter.ts b/packages/spec/src/ui/analytics-carrier-filter.ts index be786812048..5957efa928b 100644 --- a/packages/spec/src/ui/analytics-carrier-filter.ts +++ b/packages/spec/src/ui/analytics-carrier-filter.ts @@ -4,11 +4,12 @@ * The filter slot of every ANALYTICS carrier — a stored filter that is charted * through the analytics `where` door — declared once: `FilterConditionSchema` * plus the nested-relation walk that door's reach needs (see - * {@link analyticsCarrierFilter}). Three carriers declare it: `DatasetSchema`'s + * {@link analyticsCarrierFilter}). Five carriers declare it: `DatasetSchema`'s * `filter` and `DatasetMeasureSchema`'s `filter` (`./dataset.zod.ts`, #20080), - * and a dashboard widget's `filter` (`./dashboard.zod.ts`, #20116), which the - * dataset executor ANDs into the same query as the selection's - * `runtimeFilter`. + * a dashboard widget's `filter` (`./dashboard.zod.ts`), and a report's and a + * joined report block's `runtimeFilter` (`./report.zod.ts`) (#20116) — the + * presentation filters the dataset executor ANDs into the same query as the + * selection's `runtimeFilter`. * * A module of its own, and outside the `ui` barrel, so the carriers share one * declaration without it becoming published API. It moved here verbatim from @@ -186,19 +187,19 @@ function refuseNestedRelationComparands( /** * The optional filter every analytics carrier declares — `DatasetSchema.filter`, - * `DatasetMeasureSchema.filter` and `DashboardWidgetSchema.filter` — which is - * `FilterConditionSchema` plus {@link refuseNestedRelationComparands}. Every - * other schema that carries a `FilterCondition` keeps the shared schema's - * reach. A report's `runtimeFilter` (`ReportSchema`, `JoinedReportBlockSchema`) - * is charted through the same door and is not a carrier yet; adopting this is - * one line per slot. + * `DatasetMeasureSchema.filter`, `DashboardWidgetSchema.filter`, + * `ReportSchema.runtimeFilter` and `JoinedReportBlockSchema.runtimeFilter` — + * which is `FilterConditionSchema` plus {@link refuseNestedRelationComparands}. + * Every other schema that carries a `FilterCondition` keeps the shared schema's + * reach. * * The check sits on the OPTIONAL wrapper, not on `FilterConditionSchema` * itself: refining the recursive schema would clone it, and the published JSON * Schema would then inline a second copy of the condition beside the `$ref` it * carries today. On the wrapper the condition keeps its identity, so the - * published body of `ui/Dataset`, `ui/DatasetMeasure` and `ui/DashboardWidget` - * is unchanged, and the rule is recorded as a dropped refinement at each + * published body of each carrier (`ui/Dataset`, `ui/DatasetMeasure`, + * `ui/DashboardWidget`, `ui/Report`, `ui/JoinedReportBlock`) is unchanged, and + * the rule is recorded as a dropped refinement at each * carrier's `filter` in `dropped-refinements.baseline.json` * (`z.toJSONSchema()` has no projection for it). An absent filter reaches the * check as `undefined`, which the walk passes. diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index f96fd4d6166..007fe3e539e 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { FilterConditionSchema } from '../data/filter.zod'; +import { analyticsCarrierFilter } from './analytics-carrier-filter'; import { ChartConfigSchema } from './chart.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { I18nLabelSchema } from './i18n.zod'; @@ -280,8 +280,14 @@ export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObje columns: z.array(z.string()).optional().describe('Dimension names across (matrix, dataset-bound)').meta({ title: 'Columns' }), /** Measure names (from the dataset) to display. Dataset-bound only. */ values: z.array(z.string()).optional().describe('Measure names to show (dataset-bound)').meta({ title: 'Values' }), - /** Render-time scope filter, ANDed at query time. Dataset-bound only. */ - runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter (dataset-bound)').meta({ title: 'Runtime Filter' }), + /** + * Render-time scope filter, ANDed at query time. Dataset-bound only. + * [#20116] Charted through the analytics `where` door, so it is an analytics + * carrier: a comparand that door refuses INSIDE a nested relation is refused + * on save too — see {@link analyticsCarrierFilter} + * (`./analytics-carrier-filter.ts`). + */ + runtimeFilter: analyticsCarrierFilter().describe('Render-time scope filter (dataset-bound)').meta({ title: 'Runtime Filter' }), /** Result ordering for this block, most significant key first (framework#3916). */ order: z.array(ReportSortSchema).optional().describe('Result ordering, most significant key first').meta({ title: 'Order' }), }).superRefine(checkReportOrder)); @@ -409,8 +415,12 @@ export const ReportSchema = lazySchema(() => strictObject({ * `joined` report is refused — see `blocks[].values`. */ values: z.array(z.string()).optional().describe('Measure names to show'), - /** Render-time scope filter, ANDed at query time. */ - runtimeFilter: FilterConditionSchema.optional().describe('Render-time scope filter'), + /** + * Render-time scope filter, ANDed at query time. [#20116] An analytics + * carrier, like a block's — see {@link analyticsCarrierFilter} + * (`./analytics-carrier-filter.ts`). + */ + runtimeFilter: analyticsCarrierFilter().describe('Render-time scope filter'), /** * Result ordering — most significant key first (framework#3916). * From 3d9621a8789315f532dfbaee9e1edf84fbc999b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:27:28 +0000 Subject: [PATCH 7/8] chore(spec): the dropped-refinement sites the build names for the five analytics carriers Applied from build-schemas' own "corrected entries" output on top of main's side of the ledger: the widget filter, the report and joined-block runtimeFilter, and the same positions in the four installed-package envelopes (+17 sites, 0 removed; measured 605 to 622). Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 849265b1c19..0638d08198b 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 212, - "droppedRefinementSites": 605, + "droppedRefinementSites": 622, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -55,6 +55,7 @@ "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.widgets.element", + "manifest.dashboards.element.widgets.element.filter", "manifest.datasets.element", "manifest.datasets.element.filter", "manifest.datasets.element.include.element", @@ -79,6 +80,8 @@ "manifest.permissions.element.rowLevelSecurity.element", "manifest.reports.element", "manifest.reports.element.blocks.element", + "manifest.reports.element.blocks.element.runtimeFilter", + "manifest.reports.element.runtimeFilter", "manifest.reports.element.runtimeFilter.lazy", "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", @@ -156,6 +159,7 @@ "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.widgets.element", + "data.options[1].manifest.dashboards.element.widgets.element.filter", "data.options[1].manifest.datasets.element", "data.options[1].manifest.datasets.element.filter", "data.options[1].manifest.datasets.element.include.element", @@ -179,6 +183,8 @@ "data.options[1].manifest.permissions.element.rowLevelSecurity.element", "data.options[1].manifest.reports.element", "data.options[1].manifest.reports.element.blocks.element", + "data.options[1].manifest.reports.element.blocks.element.runtimeFilter", + "data.options[1].manifest.reports.element.runtimeFilter", "data.options[1].manifest.reports.element.runtimeFilter.lazy", "data.options[1].manifest.sharingRules.element.sharedWith", "data.options[1].manifest.skills.element.triggerConditions.element", @@ -241,6 +247,7 @@ "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.widgets.element", + "options[1].manifest.dashboards.element.widgets.element.filter", "options[1].manifest.datasets.element", "options[1].manifest.datasets.element.filter", "options[1].manifest.datasets.element.include.element", @@ -264,6 +271,8 @@ "options[1].manifest.permissions.element.rowLevelSecurity.element", "options[1].manifest.reports.element", "options[1].manifest.reports.element.blocks.element", + "options[1].manifest.reports.element.blocks.element.runtimeFilter", + "options[1].manifest.reports.element.runtimeFilter", "options[1].manifest.reports.element.runtimeFilter.lazy", "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", @@ -287,6 +296,7 @@ "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", + "data.packages.element.options[1].manifest.dashboards.element.widgets.element.filter", "data.packages.element.options[1].manifest.datasets.element", "data.packages.element.options[1].manifest.datasets.element.filter", "data.packages.element.options[1].manifest.datasets.element.include.element", @@ -309,6 +319,8 @@ "data.packages.element.options[1].manifest.permissions.element.rowLevelSecurity.element", "data.packages.element.options[1].manifest.reports.element", "data.packages.element.options[1].manifest.reports.element.blocks.element", + "data.packages.element.options[1].manifest.reports.element.blocks.element.runtimeFilter", + "data.packages.element.options[1].manifest.reports.element.runtimeFilter", "data.packages.element.options[1].manifest.reports.element.runtimeFilter.lazy", "data.packages.element.options[1].manifest.sharingRules.element.sharedWith", "data.packages.element.options[1].manifest.skills.element.triggerConditions.element", @@ -1167,12 +1179,14 @@ "sites": [ "globalFilters.element", "widgets.element", + "widgets.element.filter", "widgets.element.filter.lazy" ] }, "ui/DashboardWidget": { "sites": [ "", + "filter", "filter.lazy" ] }, @@ -1276,6 +1290,7 @@ "ui/JoinedReportBlock": { "sites": [ "", + "runtimeFilter", "runtimeFilter.lazy" ] }, @@ -1396,6 +1411,8 @@ "sites": [ "", "blocks.element", + "blocks.element.runtimeFilter", + "runtimeFilter", "runtimeFilter.lazy" ] }, From 830a071a4977276b0bb66b69f3861bb4c09c2ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:41:48 +0000 Subject: [PATCH 8/8] docs(spec): state the one-issue-per-slot dedupe at the reach it holds The changeset and the semantic entry said a slot a face refuses never carries a second issue. That holds at the top level and in the combinators; inside a nested relation on an analytics carrier the schema door's own $icontains and date-preset arms still judge the slot beside the faces, so a nested $icontains with a refused comparand, or a nested one-bound $between of a preset name, can carry two issues. Text only; no verdict moves. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../20116-filter-save-door-type-face-and-widget.md | 6 +++++- ...rand-types-and-widget-nested-slots-refused-at-save.ts | 9 ++++++--- packages/spec/src/migrations/registry.ts | 9 ++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.changeset/20116-filter-save-door-type-face-and-widget.md b/.changeset/20116-filter-save-door-type-face-and-widget.md index c56cc1405ef..25f610084c0 100644 --- a/.changeset/20116-filter-save-door-type-face-and-widget.md +++ b/.changeset/20116-filter-save-door-type-face-and-widget.md @@ -21,7 +21,11 @@ as the comparand itself, as an implicit-equality comparand, or as an `$in` / `$n Measured on `origin/main` `17bd3187` before the change: `FilterConditionSchema`, a dataset `filter`, a dataset measure `filter`, a dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` each parsed with `success: true` for `{ stage: { $eq: { a: 1 } } }`, `{ stage: { $in: [{ a: 1 }] } }` and a `Map` comparand, at the top level and inside a nested relation. The comparand-type face and the analytics `where` door refused each with `INVALID_FILTER` / 400. A dashboard widget `filter`, a report `runtimeFilter` and a joined report block `runtimeFilter` also parsed with `success: true` for the three nested-relation shapes above, which the analytics door refuses when they are charted. -**One slot, one issue — a dedupe; no verdict moves.** A slot raises the first refusal the query doors give, in their order: the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. Where a face refuses a slot, the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land for one defect: for example `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. Every document refused before is still refused, and every document accepted before is still accepted — only the second issue on an already-refused slot is gone. +**One issue per slot at the top level and in the combinators — a dedupe; no verdict moves.** The faces' verdict on a slot is one refusal: the first the query doors give, in their order — the comparand-shape face, then the comparand-type face, then the `$null` / `$exists` flag rule. So `{ stage: { $null: { a: 1 } } }` reads as the type face's refusal, as it does on chart. At the top level of a filter and in its `$and` / `$or` / `$not` members, where a face refuses a slot the schema door's own `$icontains` and date-preset arms stay silent on it. Before, two issues could land there for one defect: `{ created_at: { $between: ["last_7_days"] } }` reported the malformed range at `created_at.$between` AND the preset endpoint at `created_at.$between.0`; now it reports the range only, and the preset is reported once the range is fixed. + +Inside a nested relation on an analytics carrier (a dataset or measure `filter`, a widget `filter`, a report or joined-block `runtimeFilter`) a slot can still carry TWO issues. There the carrier's nested-relation walk asks the faces, while the schema door's own `$icontains` and date-preset arms keep judging nested slots as they did before this change. So `{ acct: { name: { $icontains: new Map() } } }` gets both the `$icontains` sentence and the type face's at `filter.acct.name.$icontains`, and `{ acct: { created_at: { $between: ["last_7_days"] } } }` gets the malformed range at `filter.acct.created_at.$between` and the preset endpoint at `….$between.0`. The document is refused either way; only the issue count differs. + +Every document refused before is still refused, and every document accepted before is still accepted — the dedupe removes only a second issue on an already-refused slot at the top level and in the combinators. **The words are the type face's**, less its location clause (`at where..`), because the issue's path carries the location: for example `Filter comparand is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing: …` at `filter.stage.$eq`, and at `filter.stage.$in.1` for a list member. diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts index 79f8e7ff2af..26fb56426f6 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-comparand-types-and-widget-nested-slots-refused-at-save.ts @@ -54,9 +54,12 @@ export const entry: SemanticMigration = { + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' - + 'own words less its location clause; a second issue on a slot a face already refused (the ' - + 'schema door\'s own $icontains and date-preset arms) is no longer raised, which moves no ' - + 'verdict. The widget filter and both report runtimeFilters declare the same ' + + 'own words less its location clause. At the top level of a filter and in its $and / $or / ' + + '$not members, a second issue on a slot a face already refused (the schema door\'s own ' + + '$icontains and date-preset arms) is no longer raised; inside a nested relation on an ' + + 'analytics carrier those two arms still judge the slot beside the faces, so a nested ' + + '$icontains with a refused comparand, or a nested one-bound $between of a preset name, can ' + + 'carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same ' + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6486013a130..4d31e801035 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9731,9 +9731,12 @@ const step18: MigrationStep = { + 'The save door now asks the type face itself, read-only, after the shape face, so it ' + 'refuses exactly what the face refuses and passes what it passes; one slot raises one ' + 'refusal, in the query doors\' order (shape, then type, then the flag rule), in the face\'s ' - + 'own words less its location clause; a second issue on a slot a face already refused (the ' - + 'schema door\'s own $icontains and date-preset arms) is no longer raised, which moves no ' - + 'verdict. The widget filter and both report runtimeFilters declare the same ' + + 'own words less its location clause. At the top level of a filter and in its $and / $or / ' + + '$not members, a second issue on a slot a face already refused (the schema door\'s own ' + + '$icontains and date-preset arms) is no longer raised; inside a nested relation on an ' + + 'analytics carrier those two arms still judge the slot beside the faces, so a nested ' + + '$icontains with a refused comparand, or a nested one-bound $between of a preset name, can ' + + 'carry two issues. Neither moves a verdict. The widget filter and both report runtimeFilters declare the same ' + 'analytics-carrier filter as the dataset carriers, so their nested-relation slots are ' + 'judged by the same walk: every stored filter the analytics where door charts now refuses ' + 'on save what that door refuses on chart. Metadata AT REST is not rewritten and this entry adds no D2 '