diff --git a/.changeset/19810-preview-unevaluable-filter-operator.md b/.changeset/19810-preview-unevaluable-filter-operator.md new file mode 100644 index 00000000000..94a3d42373d --- /dev/null +++ b/.changeset/19810-preview-unevaluable-filter-operator.md @@ -0,0 +1,13 @@ +--- +"@objectstack/service-analytics": patch +--- + +The draft-data preview **refuses** a `where` operator it cannot evaluate instead of answering it for every row, so a drafted chart no longer silently ignores a filter and then changes at publish (#19810). + +`preview-evaluator.ts` evaluates a pending seed draft's rows in memory — the ADR-0037 P3 Live Canvas path — and its operator switch carried ten cases (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$contains`) and then `default: return true; // unknown operator — permissive (preview, reads only)`. Every other declared operator therefore matched EVERY row: `$icontains`, `$notContains`, `$startsWith`, `$endsWith`, `$null`, `$exists`, the staged `$like` / `$ilike`, and any typo. A drafted chart with `name $icontains 'acme'` charted the whole dataset and looked exactly like a working chart; the published chart, which runs the real filter doors, applied the filter. + +- **Fail-closed, and VISIBLE.** The operator is refused in the ADR-0112 `INVALID_FILTER` / 400 envelope this package's `where` door already speaks, through `filter-normalizer`'s exported `invalidFilterError`. No new error code and no new exported symbol. Refused rather than excluded from the result: an excluded row makes the preview merely *different* from publish — zero rows where publish draws numbers — which is the silent shape `lowerPreviewDateRange` abolished on this same evaluator; only a refusal reaches the author who can fix it. It is the call `uncompilableFieldOperatorError` states for the analytics cube face, and the posture `service-analytics` already takes for `$like` / `$ilike`. +- **The vocabulary and the evaluator are now ONE table**, the shape `memory-analytics`' `MONGO_TO_CUBE_OPERATOR` took for this same defect class: adding a row is the only way to widen what this face accepts, and forgetting to add one is a loud refusal rather than a wrong number. +- **The gate does not depend on the data.** It walks `where` before any row is read, so a seed draft holding zero rows — the state a draft is authored in — refuses too instead of answering an empty chart. +- ⚠️ **What it costs**: a drafted chart whose filter uses one of those operators now returns `400 INVALID_FILTER` in preview where it previously rendered a number. That number was computed over rows the filter excludes, and it changed at publish. Growing the preview's arms is deliberately separate work — the `FILTER_OPERATORS` docblock's ruling that a name must not land ahead of its evaluators reads the same in this direction, so an arm joins the table in the PR that measures it against the shared conformance kits. +- **The ten evaluated arms are byte-for-byte unchanged**, pinned in both directions (a matching row still matches, a non-matching row still does not). diff --git a/packages/services/service-analytics/src/__tests__/preview-unevaluable-operator.test.ts b/packages/services/service-analytics/src/__tests__/preview-unevaluable-operator.test.ts new file mode 100644 index 00000000000..adbd1a776a0 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/preview-unevaluable-operator.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19810] The draft-preview matcher answers operators it cannot evaluate. + * + * ## The enumeration, read at `origin/main` (`c1dfa5241b`) before any edit + * + * `preview-evaluator.ts`'s `matchOp` switch carried exactly TEN cases — + * `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, + * `$contains` — and then, at `:109`: + * + * ``` + * default: return true; // unknown operator — permissive (preview, reads only) + * ``` + * + * So every OTHER operator matched EVERY row. Against the declared vocabulary + * (`FILTER_OPERATORS`, plus the staged `$like` / `$ilike`) the silent set was + * `$notContains`, `$startsWith`, `$endsWith`, `$icontains`, `$null`, + * `$exists`, `$like`, `$ilike` — and any typo besides. A drafted chart with + * `name $icontains 'acme'` charted the whole dataset; the published chart, + * which runs the real filter doors, applied the filter. Same shape the file's + * own `$between` case records for itself (#4081) and `lowerPreviewDateRange` + * closed for the date-range vocabulary (#16322). + * + * ## What this file pins, in the two directions the repair has + * + * 1. **Fail-closed.** An operator with no arm is REFUSED — `INVALID_FILTER` / + * 400, the envelope this package's `where` door already speaks — and the + * row it does not match is NOT answered. Refused rather than merely + * excluded: an excluded row makes the preview silently DIFFERENT from + * publish, which is the failure #16322 abolished on this same evaluator; + * a refusal makes the divergence visible to the author who can fix it. + * 2. **Unchanged.** The ten operators the face DOES evaluate answer exactly + * what they answered before, in both directions (a matching row matches, a + * non-matching row does not). A fail-closed default that starts rejecting + * rows which used to match correctly is the mirror-image defect. + * + * The refusal is pinned by its `code` + `status` (ADR-0112), not by prose: a + * bare `toThrow()` would be satisfied by any uncoded error, including the + * `TypeError` a malformed filter raises on its own. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { FILTER_OPERATORS } from '@objectstack/spec/data'; +import type { Cube } from '@objectstack/spec/data'; +import { AnalyticsService } from '../analytics-service.js'; +import { evaluateAnalyticsQueryOverRows, matchesWhere } from '../preview-evaluator.js'; + +// ── fixture ───────────────────────────────────────────────────────────────── + +const ROWS: Record[] = [ + { id: '1', name: 'Acme Corp', amount: 1200, spent_on: '2026-05-03' }, + // ⭐ the row `name $icontains 'acme'` does NOT match. Before the repair the + // preview answered it anyway; the published chart never did. + { id: '2', name: 'Globex', amount: 800, spent_on: '2026-05-12' }, +]; + +const DATASET = DatasetSchema.parse({ + name: 'expense_ds', + label: 'Expense', + object: 'expense', + dimensions: [{ name: 'name', field: 'name', type: 'string', label: 'Name' }], + measures: [{ name: 'count', aggregate: 'count' }], +}); + +const CUBE: Cube = new AnalyticsService().registerDataset(DATASET).cube; + +/** The dimension values the preview ANSWERS — empty when it refuses. */ +function namesAnswered(where: Record, rows = ROWS): string[] { + try { + const result = evaluateAnalyticsQueryOverRows( + { cube: 'expense_ds', measures: ['count'], dimensions: ['name'], where }, + CUBE, + rows, + ); + return result.rows.map((r) => String(r.name)); + } catch { + return []; + } +} + +/** The error a preview refuses with, or `undefined` if it answered. */ +function refusalFor(where: Record, rows = ROWS): (Error & { code?: string; status?: number }) | undefined { + try { + evaluateAnalyticsQueryOverRows( + { cube: 'expense_ds', measures: ['count'], dimensions: ['name'], where }, + CUBE, + rows, + ); + return undefined; + } catch (e) { + return e as Error & { code?: string; status?: number }; + } +} + +// ── direction 1 — an operator with no arm must not answer every row ───────── + +describe('[#19810] an operator the draft preview cannot evaluate', () => { + it('does NOT answer the row that `name $icontains "acme"` excludes', () => { + // ⛔ Before the repair this read `['Acme Corp', 'Globex']`: the `default` + // arm answered true for both rows, so the drafted chart counted Globex + // into a filter that excludes it. + expect(namesAnswered({ name: { $icontains: 'acme' } })).not.toContain('Globex'); + }); + + it('refuses it in the ADR-0112 `INVALID_FILTER` / 400 envelope', () => { + const err = refusalFor({ name: { $icontains: 'acme' } }); + expect(err).toBeInstanceOf(Error); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + // The operator and the face's own vocabulary are both in the message, so + // the author can see which spelling to reach for. + expect(err?.message).toContain('$icontains'); + expect(err?.message).toContain('$contains'); + }); + + it('refuses over an EMPTY seed draft too — the walk is not a function of the data', () => { + // The state a draft is authored in. A per-row refusal never fires here, so + // the preview would answer `count: 0` for a filter it cannot evaluate. + const err = refusalFor({ name: { $icontains: 'acme' } }, []); + expect(err).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + }); + + it('refuses inside `$or`, `$and` and `$not` arms', () => { + for (const where of [ + { $or: [{ name: { $startsWith: 'Ac' } }, { amount: { $eq: -1 } }] }, + { $and: [{ name: { $endsWith: 'Corp' } }] }, + { $not: { name: { $notContains: 'zzz' } } }, + ]) { + expect(refusalFor(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + } + }); + + it('refuses a constraint key that names an Object.prototype member', () => { + // A table keyed by a plain object literal would resolve `toString` to the + // inherited function and CALL it as a predicate; a `Map` cannot. + expect(refusalFor({ name: { toString: 'Acme' } })).toMatchObject({ + code: 'INVALID_FILTER', + status: 400, + }); + }); + + /** + * The declared vocabulary, minus the ten arms this face carries. Derived + * from `FILTER_OPERATORS` rather than hand-listed, so an operator added to + * the protocol without an arm here lands in this table by itself instead of + * going silent. + */ + const EVALUATED = ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$between', '$in', '$nin', '$contains']; + const UNEVALUATED = [...FILTER_OPERATORS, '$like', '$ilike'].filter((op) => !EVALUATED.includes(op)); + + it('has a non-empty unevaluated set — otherwise the table below asserts nothing', () => { + expect(UNEVALUATED).toEqual( + expect.arrayContaining(['$notContains', '$startsWith', '$endsWith', '$icontains', '$null', '$exists', '$like', '$ilike']), + ); + }); + + it.each(UNEVALUATED)('%s is refused, never answered for every row', (op) => { + const where = { name: { [op]: 'acme' } } as Record; + expect(refusalFor(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + expect(namesAnswered(where)).toEqual([]); + }); +}); + +// ── direction 2 — the ten arms are untouched ──────────────────────────────── + +describe('[#19810] the operators the draft preview DOES evaluate are unchanged', () => { + const ROW = { name: 'Acme Corp', amount: 800, spent_on: '2026-05-12' }; + + // [matches, does not match] for each arm — both directions, because a + // fail-closed default that starts REJECTING correct matches is the + // mirror-image defect of the one this card fixes. + const MATRIX: Array<[string, Record, Record]> = [ + ['$eq', { amount: { $eq: 800 } }, { amount: { $eq: 1200 } }], + ['$ne', { amount: { $ne: 1200 } }, { amount: { $ne: 800 } }], + ['$gt', { amount: { $gt: 700 } }, { amount: { $gt: 800 } }], + ['$gte', { amount: { $gte: 800 } }, { amount: { $gte: 801 } }], + ['$lt', { amount: { $lt: 900 } }, { amount: { $lt: 800 } }], + ['$lte', { amount: { $lte: 800 } }, { amount: { $lte: 799 } }], + ['$lte (bare-day, #3777)', { spent_on: { $lte: '2026-05-12' } }, { spent_on: { $lte: '2026-05-11' } }], + ['$between', { amount: { $between: [700, 900] } }, { amount: { $between: [900, 1000] } }], + ['$in', { name: { $in: ['Acme Corp', 'Globex'] } }, { name: { $in: ['Globex'] } }], + ['$nin', { name: { $nin: ['Globex'] } }, { name: { $nin: ['Acme Corp'] } }], + ['$contains', { name: { $contains: 'cme' } }, { name: { $contains: 'zzz' } }], + ['implicit equality', { name: 'Acme Corp' }, { name: 'Globex' }], + ['$and', { $and: [{ amount: { $gt: 700 } }] }, { $and: [{ amount: { $gt: 900 } }] }], + ['$or', { $or: [{ amount: { $gt: 900 } }, { name: { $contains: 'cme' } }] }, { $or: [{ amount: { $gt: 900 } }] }], + ['$not', { $not: { amount: { $eq: 1200 } } }, { $not: { amount: { $eq: 800 } } }], + ]; + + it.each(MATRIX)('%s still answers both directions', (_label, hit, miss) => { + expect(matchesWhere(ROW, hit)).toBe(true); + expect(matchesWhere(ROW, miss)).toBe(false); + }); + + it('an absent `where` still matches every row', () => { + expect(matchesWhere(ROW, undefined)).toBe(true); + expect(namesAnswered({})).toEqual(expect.arrayContaining(['Acme Corp', 'Globex'])); + }); +}); + +// ── the refusal reaches the request path ──────────────────────────────────── + +describe('[#19810] the refusal propagates through queryDataset({ previewDrafts })', () => { + it('refuses the drafted selection instead of charting every seed row', async () => { + const service = new AnalyticsService({ draftRowsResolver: vi.fn(async () => ROWS) }); + await expect( + service.queryDataset( + DATASET, + { dimensions: ['name'], measures: ['count'], runtimeFilter: { name: { $icontains: 'acme' } } }, + undefined, + { previewDrafts: true }, + ), + ).rejects.toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + }); +}); diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 227496af7e1..0e1bf30218d 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -16,6 +16,13 @@ // • order + limit/offset // Anything beyond (joins via `include`, raw SQL) falls back to the caller's // normal execution path — the preview simply doesn't claim it. +// +// [#19810] "Doesn't claim it" is a FALL-BACK only where a fall-back exists. A +// `where` operator outside the subset above has none: the rows being charted +// live only in the pending seed draft, so there is no live path to hand the +// query to. It is REFUSED — `INVALID_FILTER` / 400, the envelope this package's +// `where` door already speaks — and never answered true. See +// PREVIEW_FIELD_OPERATORS. import { calendarPartsInTzOrUtc, @@ -24,6 +31,12 @@ import { utcInstantMs, } from '@objectstack/core'; import { explicitDateRangeWindow } from './date-range-array-arm.js'; +// [#19810] The `where` door's refusal envelope — `INVALID_FILTER` / 400, +// EXPORTED by `filter-normalizer` precisely so a sibling in this package cannot +// invent a second spelling of it. A draft-preview filter is a `where`-door +// refusal in every respect that matters: the caller authored the predicate and +// the repair is theirs. +import { invalidFilterError } from './strategies/filter-normalizer.js'; import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import { emptyGroupValueFor, type Cube } from '@objectstack/spec/data'; @@ -77,39 +90,147 @@ function lteBound(value: unknown, bound: unknown): boolean { return compare(value, bound) <= 0; } -function matchOp(value: unknown, op: string, expected: unknown): boolean { - switch (op) { - case '$eq': return value === expected || String(value) === String(expected); - case '$ne': return !(value === expected || String(value) === String(expected)); - case '$gt': return value != null && compare(value, expected) > 0; - case '$gte': return value != null && compare(value, expected) >= 0; - case '$lt': return value != null && compare(value, expected) < 0; - case '$lte': { - if (value == null) return false; - // A bare-day upper bound means "through that whole day" (#3777): the SQL - // paths compile it half-open (`< day+1`), and the preview must agree or - // a drafted chart shows different numbers than the published one. String - // ordering makes `< nextDay` equivalent to `<= day` for plain date - // values, so no type lookup is needed here either. - return lteBound(value, expected); - } - case '$between': { - // Was absent, so it fell to the permissive `default` and matched EVERY - // row — a drafted chart with a range filter silently charted the whole - // dataset, then changed at publish (found by the ADR-0053 D-A3 matrix, - // #4081). The max takes the same whole-day rule as `$lte`. - if (value == null || !Array.isArray(expected) || expected.length !== 2) return false; - const [min, max] = expected; - if (min == null || max == null) return false; - return compare(value, min) >= 0 && lteBound(value, max); +/** One field operator's predicate, over one row's value. */ +type PreviewPredicate = (value: unknown, expected: unknown) => boolean; + +/** + * [#19810] The field operators this face EVALUATES — and, because the refusal + * below derives its vocabulary from these keys, the complete statement of what + * the draft preview accepts. + * + * ⛔ This was a `switch` whose `default` arm answered `return true` — "unknown + * operator — permissive (preview, reads only)". Permissive is the one thing a + * filter must never be: a predicate that answers true for every row does not + * narrow the query, it WIDENS it (#3948, #4286/ADR-0078, #5345). So a drafted + * chart carrying `name $icontains 'acme'` charted the WHOLE dataset and looked + * exactly like a working chart, until publish — where the real filter doors do + * apply the operator — changed the numbers under the author. Every declared + * operator with no row below was in that state: `$icontains`, `$notContains`, + * `$startsWith`, `$endsWith`, `$null`, `$exists`, plus `$like` / `$ilike` and + * any typo. "Reads only" argued the wrong half: the preview writes nothing and + * reports a NUMBER, and a wrong number is what a chart is. + * + * It is the identical shape this file already records twice — `$between` fell + * to that same `default` and matched every row (#4081), and `dateRange` + * degenerated to a point window (#16322) — because publish materialises the + * SAME seed, so any disagreement here makes the numbers jump across the publish + * boundary for no reason an author can see. + * + * Vocabulary and evaluator are ONE table, the shape `memory-analytics`' + * `MONGO_TO_CUBE_OPERATOR` took for this exact defect (#5345): adding a row + * here is the only way to widen what this face accepts, and forgetting to add + * one is a loud refusal rather than a wrong number. A `Map`, not an object + * literal, so a field constraint naming an `Object.prototype` member + * (`{ name: { toString: 'x' } }`) cannot resolve to an inherited function and + * be called as a predicate. + * + * ⛔ Widening it is deliberately NOT this card's work, and the ordering is + * already ruled: the `FILTER_OPERATORS` docblock's #6520 constraint — the word + * list must not land ahead of the evaluators — reads the same in this + * direction, so an arm joins this table in the PR that measures it against the + * shared text/temporal conformance kits, not before. Every row below is + * byte-for-byte the `case` it replaces. + */ +const PREVIEW_FIELD_OPERATORS = new Map([ + ['$eq', (value, expected) => value === expected || String(value) === String(expected)], + ['$ne', (value, expected) => !(value === expected || String(value) === String(expected))], + ['$gt', (value, expected) => value != null && compare(value, expected) > 0], + ['$gte', (value, expected) => value != null && compare(value, expected) >= 0], + ['$lt', (value, expected) => value != null && compare(value, expected) < 0], + ['$lte', (value, expected) => { + if (value == null) return false; + // A bare-day upper bound means "through that whole day" (#3777): the SQL + // paths compile it half-open (`< day+1`), and the preview must agree or + // a drafted chart shows different numbers than the published one. String + // ordering makes `< nextDay` equivalent to `<= day` for plain date + // values, so no type lookup is needed here either. + return lteBound(value, expected); + }], + ['$between', (value, expected) => { + // Was absent, so it fell to the permissive `default` and matched EVERY + // row — a drafted chart with a range filter silently charted the whole + // dataset, then changed at publish (found by the ADR-0053 D-A3 matrix, + // #4081). The max takes the same whole-day rule as `$lte`. + if (value == null || !Array.isArray(expected) || expected.length !== 2) return false; + const [min, max] = expected; + if (min == null || max == null) return false; + return compare(value, min) >= 0 && lteBound(value, max); + }], + ['$in', (value, expected) => Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e))], + ['$nin', (value, expected) => Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e))], + ['$contains', (value, expected) => String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase())], +]); + +/** + * [#19810] A filter operator this face cannot evaluate, in the ADR-0112 + * envelope every sibling filter refusal in this package speaks. + * + * ⛔ REFUSED, not answered-true and not silently excluded from the result. The + * three candidates are not equivalent: answering true is the defect; excluding + * the row makes the preview merely DIFFERENT from the published chart — zero + * rows where publish draws numbers — which is the silent failure #16322 + * abolished on this very evaluator; only a refusal makes the disagreement + * VISIBLE to the author who can fix it. That is the call + * `uncompilableFieldOperatorError` states for the analytics cube face, and the + * posture `service-analytics` already takes for `$like` / `$ilike` (the + * `FILTER_OPERATORS` face table: "REFUSE, loudly, in the ADR-0112 + * `INVALID_FILTER` envelope"). + * + * This face is the ONLY door on the path it serves: `queryDataset`'s preview + * branch evaluates in memory and never reaches a strategy, so no `where` gate + * runs ahead of it — the same reason `lowerPreviewDateRange` refuses here + * rather than trusting the schema door behind it. + */ +function previewUnevaluableOperatorError(op: string, field: string): Error { + const supported = [...PREVIEW_FIELD_OPERATORS.keys()].join(', '); + return invalidFilterError( + `[analytics] Filter operator "${op}" on field "${field}" is not evaluated by the draft-data ` + + `preview. Operators this face evaluates: ${supported}. It is refused rather than answered ` + + `true for every row: a predicate that matches everything does not narrow the query, it ` + + `WIDENS it — the drafted chart is drawn over rows the filter excluded and looks like a ` + + `working chart, until publish applies the operator and the numbers change. Rewrite the ` + + `predicate with an operator listed above, or publish the seed and chart it live.`, + ); +} + +/** + * [#19810] Refuse a `where` this face cannot evaluate BEFORE any row is read. + * + * Row-independent on purpose. {@link matchOp}'s refusal can only fire if some + * row reaches it, and a pending seed draft holding ZERO rows — the state a + * draft is authored in, and the state a `$null` filter over an empty seed lands + * in — would otherwise answer an unevaluable filter with an empty result and no + * complaint. `driver-memory`'s `assertFilterConditionShape` runs ahead of that + * driver's lowering for the same reason: the walk must not be a function of the + * data. + * + * It mirrors {@link matchesWhere}'s own traversal exactly, malformed shapes + * included — a non-array `$and` is left for `matchesWhere` to fault on as it + * always has, so this gate widens no refusal beyond the operator vocabulary. + */ +function assertPreviewCanEvaluate(where: Record | undefined): void { + if (!where) return; + for (const [key, cond] of Object.entries(where)) { + if (key === '$and' || key === '$or') { + if (Array.isArray(cond)) for (const arm of cond) assertPreviewCanEvaluate(arm as Row); + } else if (key === '$not') { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + assertPreviewCanEvaluate(cond as Row); + } + } else if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + for (const op of Object.keys(cond as Row)) { + if (!PREVIEW_FIELD_OPERATORS.has(op)) throw previewUnevaluableOperatorError(op, key); + } } - case '$in': return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e)); - case '$nin': return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e)); - case '$contains': return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase()); - default: return true; // unknown operator — permissive (preview, reads only) } } +function matchOp(value: unknown, op: string, expected: unknown, field: string): boolean { + const evaluate = PREVIEW_FIELD_OPERATORS.get(op); + if (!evaluate) throw previewUnevaluableOperatorError(op, field); + return evaluate(value, expected); +} + export function matchesWhere(row: Row, where: Record | undefined): boolean { if (!where) return true; for (const [key, cond] of Object.entries(where)) { @@ -121,7 +242,7 @@ export function matchesWhere(row: Row, where: Record | undefine if (matchesWhere(row, cond as Row)) return false; } else if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { for (const [op, expected] of Object.entries(cond as Row)) { - if (!matchOp(row[key], op, expected)) return false; + if (!matchOp(row[key], op, expected, key)) return false; } } else if (!(row[key] === cond || String(row[key]) === String(cond))) { return false; // implicit equality @@ -426,6 +547,10 @@ export function evaluateAnalyticsQueryOverRows( rows: Row[], ): AnalyticsResult { // 1. Row-level filters: `where`, then timeDimension dateRanges. + // [#19810] The operator vocabulary is decided BEFORE the rows are read, so an + // unevaluable predicate refuses over an empty seed draft too — see + // {@link assertPreviewCanEvaluate}. + assertPreviewCanEvaluate(query.where); let filtered = rows.filter((r) => matchesWhere(r, query.where)); const timeDims = query.timeDimensions ?? []; for (const td of timeDims) {