Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/20035-analytics-where-type-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics)!: the analytics `where` door runs the shared comparand-TYPE face on the object spelling, so a plain-object, binary, `Map`, class-instance, oversized-bigint or `undefined` comparand is refused like the `FilterArray` spelling and the engine refuse it (#20035)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) the #7872 comparand-type transition is not on the ADR-0087 ledger: no entry names normalizeFilterComparandTypes or its accepted set, and #7872's own changeset declared no breaking change. This change adds no new transition either; it brings the analytics object-form `where`, its dataset and measure filters and the draft-data preview under the refusal that face already makes at every other door. No ledger entry could carry it: a plain object in a comparand slot has no accepted comparand it can be mechanically rewritten to, and a binary, a Map, a class instance, a bigint beyond 2^53 and undefined cannot be stored as JSON metadata at all. Which accepted comparand the author meant is an authoring decision. A bigint within 2^53 is narrowed to its number and answers the same rows. The table below records the verdict each cell had and has; it prescribes no rewrite. -->

**BREAKING**: this narrows what the analytics faces of `@objectstack/service-analytics` accept. A caller `where`, a dataset `filter` or a measure `filter` in the object spelling that carries one of the comparands below compiled before this change, at any depth under `$and` / `$or` / `$not` or inside a nested relation. It is now refused with `INVALID_FILTER` / 400, in the shared face's own message, path and prescription, before any SQL statement runs or any `engine.aggregate` call is made. It ships as `minor` under the launch-window convention for accept-set narrowings.

| comparand in an object-form `where` | before (native SQL execute) | now, on every face |
|:--|:--|:--|
| a plain object under a scalar operator: `{ stage: { $ne: { a: 1 } } }`, `$gt`, `$eq`, the LIKE family | bound as the JSON text `'{"a":1}'`; `$ne` served EVERY row, the others matched nothing; the `/analytics/sql` echo answered `DATABASE_ERROR` / 500 | refused 400: "Filter comparand at where.stage.$ne is a plain object …" |
| a plain object as a `$between` endpoint or an `$in` / `$nin` member | the endpoint was compared as JSON text; the member was refused in this package's own sentence | refused 400, in the face's sentence |
| a `{ $field: 5 }` object (a non-string `$field`) under an ordering operator | bound as JSON text (it is not a field reference) | refused 400 as a plain object |
| a binary (`Uint8Array` / `Buffer`) under `$eq` / `$ne` or as an `$in` member | bound as the JSON text `'{"0":1,…}'`, never as a blob; `$ne` served every row | refused 400 |
| a binary, a `Map` or a class instance as the implicit comparand `{ stage: VALUE }` | read as a NESTED RELATION (`stage.0 = 1 AND …`, a 500 on the native path) or refused as a zero-operator wrapper | refused 400 as the value it is |
| a `bigint` beyond ±2^53 | bound as-is, answering no row | refused 400 |
| `undefined`, implicit or under any operator, including `$null` / `$exists` | refused 400 in this package's own sentence, except `{ $null: undefined }`, which compiled to IS NOT NULL; the draft preview answered no row | refused 400, in the face's sentence |

The shared comparand-type face in `@objectstack/spec` (`normalizeFilterComparandTypes`) is the #7872 door: its accepted comparand types are `string | number | bigint | boolean | null | Date`, and it refuses everything else loudly at the compile face (maintainer ruling, 2026-08-12). `parseFilterAST` runs it on everything it returns and the ObjectQL engine's seam on every object-form `where`, so the `FilterArray` spelling of this door and the engine path already refused each row above. The object spelling now meets it too, after the comparand-shape face and before any node is built, the order `parseFilterAST` uses. Both spellings of one condition get the same refusal, byte for byte, on the native SQL execute path, the `/analytics/sql` echo, the ObjectQL engine path and the draft-data preview.

A `bigint` within ±2^53 is not refused: the face narrows it to its number, and the condition every face lowers is the narrowed one. The rows do not change on the published faces. The draft-data preview used to order a bigint as text (`{ amt: { $gt: 2n } }` lost the row holding 10); it now evaluates the narrowed number and charts the published rows.

Binary comparands are reconciled with the face rather than kept as a declared local extra of this package. Measured before this change, the `where` door never compared a binary as a blob on any face: the native path bound it as JSON text, the engine path and the `FilterArray` spelling refused it, and no producer can send one over JSON. This change does not touch the read-scope door, which refuses a binary comparand too since the read-scope lowering began running the same face (#20018), so a binary is now refused at both analytics doors.

Refusals this door already gave in its own words now read in the face's words, the same words the `FilterArray` spelling gets: an `undefined` comparand, and a plain-object `$in` / `$nin` member or LIKE-family comparand. Their verdict, code and status are unchanged. The positions the face does not judge keep this package's sentences: an array or a `{ $field }` reference as a list member or a LIKE comparand, and an `undefined` inside an array comparand or under an operator outside the vocabulary.

Who is affected: nothing in this repository's examples, seeds, docs or package sources authors any of the refused comparands. A text scan over 4013 non-test files found none, and it did find the shape in a code comment written for this change. Stored datasets, dashboard widget filters and report runtime filters in a deployment were NOT measured. Of the refused values, only the plain object can be stored as JSON.

The refusal both analytics doors give for an unbindable `$in` / `$nin` / `$between` member no longer offers "(or a binary value)" as a repair, because neither door accepts a binary any more. It now names only the accepted set; its code, status and verdict are unchanged.

Not changed: every string, number, boolean, `null` and `Date` comparand; a `{ $field }` reference in an ordering slot (served on the engine path); nested relations and dotted members; `$ne` with a list, which the shared face does not judge yet.
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,23 @@
* - **`undefined` is REFUSED by both doors** — `assertDefinedComparands`
* (#6386, on #6050's ruling B) at the `where` door and #6125's upstream
* refusal at the read scope, both of which fire before a predicate is
* consulted. The `undefined` arms inside the predicates, and `comparand()`'s
* consulted. [#20035] At the `where` door the shared comparand-type face
* now fires first, in its own words; same verdict, same envelope. The `undefined` arms inside the predicates, and `comparand()`'s
* normalise-to-`null`, survive as deliberately-kept dead arms (#5526's call
* to reopen, not this card's). The `undefined` row below is what makes that
* checkable instead of asserted — it pins REFUSAL, at both doors.
* - **binary** binds but has no faithful text rendering, so it is accepted in
* a bind position and refused by the LIKE family. That asymmetry is the
* reason the package carries two predicates rather than one with a flag.
* [#20018] On the READ-SCOPE door it is now refused in every position: the
* At the PREDICATE level it still is, and at neither DOOR is it reachable
* as an accepted comparand any more: both now run the shared comparand-type
* face, which does not admit binary.
* [#20018] On the READ-SCOPE door it is refused in every position: the
* lowering runs the shared comparand-type face after its own gates, as the
* ObjectQL execute face does. The predicate's own answer is unchanged.
* ObjectQL execute face does.
* [#20035] On the `where` DOOR it is refused in every position too: the
* door runs the same face before any predicate is asked (see the binary
* row's note for the evidence the keep-or-reconcile call rests on).
*
* @see comparand-shape.ts — the predicates and the messages this pins
* @see https://github.com/objectstack-ai/objectstack/issues/8186
Expand Down Expand Up @@ -150,31 +157,50 @@ const MATRIX: readonly Row[] = [
// ── the two package-local predicate admissions, neither reachable ──────────
// `undefined`: the predicates admit it; BOTH doors refuse it upstream (#6386
// at the `where` door, #6125 at the read scope) before a predicate is asked.
// [#20035] At the `where` door the shared comparand-type face now answers
// first, in its own words; the verdict and envelope below are unchanged.
{ label: 'undefined', value: undefined,
bindable: true, renderable: true,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
// binary: binds, does not render — accepted where it binds, refused by LIKE.
// [#20018] Two cells moved AFTER the #8186 measurement, on purpose: the
// read-scope lowering now runs the shared comparand-type face after its own
// gates, and that face does not admit binary (the predicate above it still
// does — the `where` door keeps the extra, the read scope no longer reaches
// it). The ObjectQL execute face already refused a binary read-scope
// Four cells of this row moved AFTER the #8186 measurement, each named with
// the change that moved it; `whereLike` / `scopeLike` were refused from the
// start, and the predicate columns never moved.
// [#20018] `scopeIn` / `scopeEq`: the read-scope lowering now runs the shared
// comparand-type face after its own gates, and that face does not admit
// binary. The ObjectQL execute face already refused a binary read-scope
// comparand.
// [#20035] `whereIn` / `whereEq`: RE-JUDGED from accept to refused, on
// purpose and with the evidence stated, because the type face's docblock
// lets a door keep binary only as a DECLARED local extra and #8186 asked for
// an explicit keep-or-reconcile call. Reconciled: the maintainer's ruling on
// #7872 (2026-08-12) 「refuses everything else loudly at the compile face」,
// and this door never delivered the extra — measured on a real engine before
// the change, the native path bound the buffer as the JSON TEXT
// '{"0":1,"1":2}' (no row; `$ne` served every row), the engine path and the
// FilterArray spelling refused it, and no producer can send one over JSON.
// So the extra is now reachable from neither door; the predicate above still
// admits it.
{ label: 'binary', value: new Uint8Array([1, 2]),
bindable: true, renderable: false,
whereLike: REFUSED_WHERE, whereIn: OK, whereEq: OK,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },

// ── shapes outside the fence ──────────────────────────────────────────────
// `$eq` accepts them on purpose: #5234 left the `{$eq: {…}}` account alone.
// [#20018] …on the `where` door. On the read-scope lowering the shared
// comparand-type face now closes that account (#7872's set), the answer the
// ObjectQL execute face already gave: a plain object bound as a scalar was
// refused by the database at execution, not by this package.
// `$eq` accepted them on purpose: #5234 left the `{$eq: {…}}` account alone.
// Both `$eq` cells have moved since, each by the #7872 set (「refuses
// everything else loudly at the compile face」) reaching one more door:
// [#20018] `scopeEq`: the read-scope lowering now runs the shared
// comparand-type face, the answer the ObjectQL execute face already gave (a
// plain object bound as a scalar was refused by the database at execution,
// not by this package).
// [#20035] `whereEq`: RE-JUDGED to refused — the `where` door now runs the
// same face on the object spelling, as `parseFilterAST` and the engine seam
// always did.
{ label: 'plain object', value: { foo: 1 },
bindable: false, renderable: false,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: OK,
whereLike: REFUSED_WHERE, whereIn: REFUSED_WHERE, whereEq: REFUSED_WHERE,
scopeLike: REFUSED_SCOPE, scopeIn: REFUSED_SCOPE, scopeEq: REFUSED_SCOPE },
// [#19975] One cell of this row moved AFTER the #8186 measurement, on
// purpose: ruling 乙 (#19757) refuses a list in the equality slot, and the
Expand Down Expand Up @@ -276,11 +302,27 @@ describe('[#8186] the comparand matrix is unchanged by the door reconciliation',
});

it('binary stays a package-local extra the door does not admit', () => {
// [#20035] At the predicate level, which is what this case reads. The
// `where` door refuses binary before this predicate is asked (the matrix
// row above); the read-scope door still asks it and then refuses the
// value with the shared type face after its own gates (#20018), so the
// extra is admitted here and served at neither door.
const buf = new Uint8Array([1, 2]);
expect(isAcceptedFilterComparand(buf)).toBe(false);
expect(isBindableComparand(buf)).toBe(true);
expect(unbindableListMemberMessage('$in', 'status', { foo: 1 }, 0))
.toContain('(or a binary value)');
// [#20035] RE-JUDGED — FLIPPED. This asserted the refusal sentence offered
// "(or a binary value)" as a repair, the parenthetical #8186 kept beside
// the door's sentence. Neither door accepts a binary any more: the
// read-scope lowering refuses it with the shared comparand-type face
// (#20018) and so does the `where` door (#20035, the #7872 ruling:
// 「refuses everything else loudly at the compile face」). A refusal that
// still prescribed it sent the author to a value the same door refuses,
// so the sentence now names the accepted set and nothing more. The
// predicate's binary arm above is unchanged — it is `driver-sql`'s
// mirror, and `driver-sql`, which does bind a binary, keeps its own copy.
const message = unbindableListMemberMessage('$in', 'status', { foo: 1 }, 0);
expect(message).not.toContain('binary');
expect(message).toContain(`use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE}. Refusing rather than binding it`);
});
});

Expand Down
Loading
Loading