Commit 100c394
Fixes #21448
Clause-②: no (narrowing)
## What this changes
The shared comparand-shape face (`assertListComparandShapes`,
`@objectstack/spec/data`) now refuses a LIST at every scalar operator,
whatever the column type. That covers `$gt`, `$gte`, `$lt`, `$lte`, the
text operators (`$contains`, `$notContains`, `$startsWith`, `$endsWith`,
`$icontains`, `$like`, `$ilike`) and the flags (`$null`, `$exists`,
`$empty`). `$eq` and `$ne` keep their own ruled arms.
- **Envelope:** `INVALID_FILTER` / 400, before any read.
- **Sentence:** one sentence naming the operator, the field, the list
and the path. Its leading clause is `driver-memory`'s
`arrayComparandError` for the same condition, word for word.
- **Remedy:** one value; `$in` (authoring `in`) for "one of these
values"; `$between` (authoring `between`) for a range.
This implements triage's ruling (5958292323) as written. There is one
verdict, at the shared face. It is not in the number or boolean
declared-type verdicts. The lowering gains no second rule and no
`values[0]` read of a list.
## Measured on `origin/main` `b94a2a727`, SQLite and PostgreSQL 16.14
alike
Through `AnalyticsService.query` / `.queryDataset` (what `POST
/api/v1/analytics/query` and `/api/v1/analytics/dataset/query` relay),
on both faces, and through `engine.find` on the real `ObjectQL`:
| filter | engine-aggregate face | native face | `engine.find` |
|---|---|---|---|
| `{ amount: { $gt: [10, 99] } }` (number) | **200, 2** (the driver got
`$gt: 10`) | 400, the number verdict | 400, the number verdict |
| `{ amount: { $gt: [10] } }` | **200, 2** | 400, the number verdict |
400, the number verdict |
| `{ amount: { $lte: [12, 1] } }` | **200, 2** (`$lte: 12`) | 400, the
number verdict | 400, the number verdict |
| `{ note: { $gt: ['a', 'z'] } }` (text) | **200, 3** (`$gt: 'a'`) |
**200, 3** (bound `'a'`) | 400, driver-sql's bind refusal |
| `[['note', '>', ['a', 'z']]]` | **200, 3** | **200, 3** | 400,
driver-sql's |
| `{ note: { $eq: ['b'] } }`, `{ note: { $ne: ['b'] } }` | 400, the face
| 400, the face | 400, the face |
| `{ note: { $contains: ['b', 'm'] } }` | 400, this package's LIKE gate
| 400, the same | 400, driver-sql's |
| controls: `$in: ['b']`, `$nin: ['b']`, `$gt: 10` | 1 / 2 / 2 | the
same | the same |
**Triage's "measure first": the engine door's own answer for the text
cell.** The engine door does NOT bind the first member. On `driver-sql`
it refused 400 in the driver's own words ("…cannot be bound as a SQL
parameter…"). So its answer was right and only its wording was per
driver. The same verdict now answers it first, in the face's words
(pinned: the `engine.find` text cell). One position over,
`driver-memory` ANSWERS a list at a text operator
(`memory-matcher-array-and-date-comparand.test.ts`). The face now
refuses that before any driver runs.
**Dispatch assumptions this measurement corrected:**
- `$eq: [x]` / `$ne: [x]` already answered one 400 on both faces (#19757
/ #19886's arms). The live defect was the ordering operators, plus a
text column's native face.
- The lowering reaches the face through `filter-normalizer.ts`'s
`assertWhereComparandShapes` (#20010), not through `comparand-shape.ts`.
## Design
- **The operator set is the spec's own:** `SCALAR_COMPARAND_OPERATORS`,
the comparand-TYPE face's split, which `filter-comparand-type.test.ts`
reconciles against `FieldOperatorsSchema`'s keys.
- It moved verbatim from `filter-comparand-type.ts` into a new module
outside the `data` barrel (`filter-comparand-operators.ts`). Both faces
and the save door read ONE split, and nothing is published:
`check:api-surface` is unchanged.
- The face test also derives the arm's operators from the schema: every
declared operator whose enforced slot refuses an array, which is all but
`$in` / `$nin` / `$between`.
- **No rule in the lowering.** `lowerAnalyticsWhere` already hands every
field entry to the face before any leaf exists. So the arm reaches both
analytics faces at every position (`where`, `runtimeFilter`, a dataset's
scope, a measure's `filter`) with no code change there.
`filter-normalizer.ts` and `comparand-shape.ts` change docblocks only;
they state the invariant that only a list operator's array is spread
into a leaf's `values`.
- **The save door asks the same face** (`filter-save-door-refusals.ts`).
- A stored dataset, measure, widget or report filter carrying the shape
is refused on save, in the face's sentence less its location. The parity
test's §2 requires a save-door sentence for every face arm.
- The HTTP routes that Zod-parse a filter in their body therefore answer
`VALIDATION_FAILED` / 400, located on the member, as for every other
face arm. In-process callers get `INVALID_FILTER` / 400. Both layers are
pinned.
- **The native number arm (PR #21446).** `judgedComparands` lowers
through `lowerAnalyticsWhere` first, so that arm's `array` refusal is no
longer reached at a scalar operator from any native position.
`native-sql-strategy.ts` is untouched; the now-unreachable branch is a
note, not an edit.
- **Flags.** A list at `$null` / `$exists` / `$empty` now reads in the
shape sentence, because how many values comes before which value. A
non-boolean scalar flag keeps the boolean rule's sentence.
## Pins
- **New, `service-analytics`:**
`list-at-scalar-operator-both-faces.test.ts`, run on SQLite and
PostgreSQL. Each measured cell, plus `$gte` / `$lt: []` / `$contains` /
`$startsWith`, `$or` / `$not`, and the FilterArray spelling, is checked
at both doors.
- Each cell answers one 400 on both faces, with the same message on each
face and no raw statement, engine aggregate or driver read.
- A registered dataset's scope and measure filter are refused the same
way. `DatasetSchema` refuses the stored filter on save, in the sentence
less its location.
- `engine.find` refuses the text cell in the face's words, not the
driver's.
- Controls (`$in`, `$nin`, scalar `$gt`, `$between`) count alike on both
faces.
- **Both-faces pin:** PR #21446's native-only `$gt: [10]` cell is now a
both-faces cell in `native-sql-number-comparand-door.test.ts`.
- **New, `@objectstack/spec`:** a `filter-comparand-shape.test.ts` block
covering the derived operator set; every list shape (pair, one member,
strings, empty); nested paths; every AST spelling that carries a value;
the message and remedy (`$in`, `$between`, both declared); flags;
controls; the 500-char bound.
- **Moved because the face now answers first** (each row left its old
table and is pinned as the shape face's):
- the declared-type corpora (`filter-number-` /
`filter-boolean-comparand-declared-type.ts`) and their tests: list rows
only at list members now;
- `filter-save-door-face-parity.test.ts` (§1: every declared operator is
face-judged; §2: new rows);
- objectql's number, boolean and aggregate-flag doors;
- REST's number and boolean data doors;
- `analytics-filter-refusal-envelope.test.ts` (a new HTTP cell);
- analytics' flag, `$empty` and type-face tests.
## Ablations
Each leg was committed first, mutated through
`scripts/ablation-replace.mjs` (WRAP, with the restore trapped), and its
restore proven by blob == HEAD and an empty `git diff HEAD`.
- **A: the spec arm deleted.** Rebuilt; `ablation-dist-preflight.mjs
@objectstack/spec 'throw arrayScalarComparandError(' --absent` exit 0.
- **Predicted:** each list-at-scalar cell goes back to a 200 (or to the
native number verdict on a number column); `$eq` / `$ne` and the
controls stay green.
- **Observed:** analytics went 60 failed / 142 passed (30 cells × SQLite
and PG):
- the text and number cells were answered 200 on the engine-aggregate
face;
- the LIKE cells fell to the analytics LIKE gate's words;
- `engine.find` answered in driver-sql's words;
- the save door accepted the stored filter;
- every `$eq` / `$ne` and control cell stayed green.
- The face test's new block went 12 red. Restore leg: rebuilt, marker
present in `dist/`, tree clean, 202 / 202 green.
- **B: the lowering's consumption deleted**
(`assertWhereComparandShapes`' face hand-over). The subject resolves
from `src`, so no rebuild is owed.
- **Predicted:** the object-spelling analytics cells go red; the
FilterArray spelling, `$eq`, the save door and `engine.find` stay green.
- **Observed:** 48 failed (24 cells × 2 drivers: the object-spelling
cells, `$ne` included, and the registered scope and measure). 0 failures
among the FilterArray, `$eq`, save-door and `engine.find` cells.
## Verification, at the merged head `2b9fd4f5e` (`origin/main` merged
in)
- **Full suites:**
- `@objectstack/spec` test: 602 files / 17754 tests green.
- `@objectstack/service-analytics` test, with PostgreSQL 16.14: 172
files green. One file's 4 live-PG cells need a UTC server; see the
acceptance notes.
- `@objectstack/objectql` test: 366 files green. One barrel-import test
timed out at 5 s at load ~7, then 34 / 34 when run alone.
- REST door pins: 4 files, 67 tests (MySQL cells are named skips).
- **Typecheck:** spec, service-analytics, objectql and rest all green.
- **Face importers, at the pre-merge head:** driver-memory,
driver-mongodb, driver-turso, driver-sql (with a non-UTC PostgreSQL
server), lint, metadata-core, metadata-protocol, plugin-security and
plugin-sharing are all green.
- **Gates:** `dispatch-gates.mjs --commands` at `2b9fd4f5e` derives 90
families. All 90 ran with recorded exit codes, all 0, including
`check:dual-build-cjs-loads` (105 require entries across 66 packages
load). `--ran` reconciles 90 run / 0 NOT MEASURED.
- **Lint (narrowed, measured):** `eslint --no-inline-config --format
json` over the 24 changed `.ts` files gives 24 files, 0 errors, 0
warnings, none ignored.
- The population is read from eslint's own output.
- Invariance: `eslint.config.mjs` has no type-aware linting (no
`parserOptions.project` / `projectService`), so this diff cannot move an
untouched file's verdict.
- **Docs:** grepping `content/docs/**` (outside `releases/`) and
`skills/**` for the comparand-shape rules and the filter operators found
no sentence made false.
## Blast radius
- **Shipped producers** writing a list at a scalar operator (object
form, `[field, op, value]` and `{ field, operator, value }`, across
`examples/`, `skills/`, `content/docs/`, `apps/` and `packages/**`
non-test sources): none. The CEL lowering already refuses one
(`cel-to-filter.ts`).
- **NOT MEASURED:** objectui's console filter builder. `../objectui` is
not checked out here, and `packages/console/dist` is not built.
## File surface against the claim
The claim named `filter-normalizer.ts`, `comparand-shape.ts`, spec
`filter-comparand-shape.ts` and its test, the pins and the changeset.
Added, each a consequence of the narrowing inside the rule's consumer
radius:
- `filter-comparand-operators.ts` (new, internal);
- `filter-comparand-type.ts` (the split's import, and one now-false
sentence);
- `filter-comparand-refusal-text.ts` (the shared sentence);
- `filter-save-door-refusals.ts` (the save door's sentence);
- the two declared-type corpora and the parity test;
- the objectql, REST and analytics pins listed above.
None of `native-sql-strategy.ts`, `objectql-strategy.ts`,
`analytics-service.ts` or `preview-evaluator.ts` is touched.
## Acceptance notes
- **Out of scope; reported to the seat, not filed here.** On PostgreSQL
with the server TimeZone set to `Asia/Shanghai`,
`objectql-face-order-limit.test.ts`'s live cells answer the newest month
bucket of a `date` column holding `2026-06-01` as `2026-05` (2 rows). At
UTC the answer is `2026-06` (1 row). The cell is the engine-aggregate
face, since the native face declines granularity. Not touched by this
diff.
- The compilers' `values[0]` reads stay as they are. With the face's
arm, no list reaches a scalar leaf through any analytics door.
- PR #21452 (which held the analytics strategies) landed while this was
open. It was merged in at `2b9fd4f5e` cleanly, with no overlap.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48fa7a3 commit 100c394
25 files changed
Lines changed: 1117 additions & 100 deletions
File tree
- .changeset
- packages
- objectql/src
- rest/src
- services/service-analytics/src
- __tests__
- strategies
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 22 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
154 | 154 | | |
155 | 155 | | |
156 | 156 | | |
157 | | - | |
158 | | - | |
159 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
| |||
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
187 | 206 | | |
188 | 207 | | |
189 | 208 | | |
| |||
Lines changed: 31 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
428 | 428 | | |
429 | 429 | | |
430 | 430 | | |
431 | | - | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
432 | 434 | | |
433 | 435 | | |
434 | 436 | | |
| |||
502 | 504 | | |
503 | 505 | | |
504 | 506 | | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
505 | 535 | | |
506 | 536 | | |
507 | 537 | | |
| |||
Lines changed: 36 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
451 | 451 | | |
452 | 452 | | |
453 | 453 | | |
454 | | - | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
455 | 457 | | |
456 | 458 | | |
457 | 459 | | |
| |||
500 | 502 | | |
501 | 503 | | |
502 | 504 | | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
503 | 538 | | |
504 | 539 | | |
505 | 540 | | |
| |||
Lines changed: 10 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
292 | 292 | | |
293 | 293 | | |
294 | 294 | | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
295 | 305 | | |
296 | 306 | | |
297 | 307 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
94 | | - | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
95 | 97 | | |
96 | 98 | | |
97 | 99 | | |
| |||
245 | 247 | | |
246 | 248 | | |
247 | 249 | | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
248 | 284 | | |
249 | 285 | | |
250 | 286 | | |
| |||
0 commit comments