fix(analytics): refuse a zero-operator field constraint on the draft preview - #19896
Conversation
…preview
`matchesWhere` iterated a field constraint's entries; `{ name: {} }` has
none, so the row fell through to a match and the draft preview charted
every row for a filter all three shipped drivers refuse. Refuse it in the
INVALID_FILTER / 400 envelope the preview already speaks, from the
row-independent gate (whole tree, nested combinators included) and from
matchesWhere's field arm.
Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin eb6f2dc0822af699288a76a99b14f2381f106229 && git checkout eb6f2dc0822af699288a76a99b14f2381f106229
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dabf8d795ee9279c18b4b00bfabb322544106b2f 3adb37d4276dcc3fd3b41149df851b1b3b632fdc && git checkout -B drift-repro dabf8d795ee9279c18b4b00bfabb322544106b2f && git merge --no-ff 3adb37d4276dcc3fd3b41149df851b1b3b632fdc
node scripts/docs-audit/affected-docs.mjs --json dabf8d795ee9279c18b4b00bfabb322544106b2f |
Contract reviewServed-tier: An isolated reviewer ran at the served tier. It saw only the card #19835, the triage direction 5793123019, and this PR. The ① Derived judgments11 claims checked. All RIGHT.
Non-blocking:
② Semver level
③ Boundary flagsZero Implemented-by: VERDICT: PASS Generated by Claude Code |
Closes #19835
Clause-②: no
What
packages/services/service-analytics/src/preview-evaluator.ts: the draft-data preview now refuses a field constraint with zero operators ({ name: {} }). Before this change it matched every row.The defect.
matchesWhere's per-field arm iteratedObject.entries(cond). An empty object has no entries, so the loop body never ran and the row fell through to the closingreturn true. The card's probematchesWhere({ name: 'Globex' }, { name: {} })answeredtrue. The operator-vocabulary refusal that PR #19833 added cannot reach this case: with no key there is nothing to look up. All three shipped drivers refuse the shape (driver-memoryfilter-refusal.ts,driver-mongodbmongodb-filter.ts, anddriver-sqlboth at the top level and inside combinators). This package's ownwheredoor also refuses it (filter-normalizer's wrapper arm). So the drafted chart showed every row for a filter that publish refuses outright.The repair, using the refusal plumbing PR #19833 added:
isEmptyFieldConstraint, mirrored locally. It matches a plain object with zero own keys. The prototype check keeps aDate/RegExp/ class instance out of it, because those are comparands. The predicate has the same shape as the drivers' copies. The exported copy lives indriver-memory, whichservice-analyticsdoes not depend on. The card says not to add a cross-package dependency for this, so none was added.previewEmptyFieldConstraintError: the ADR-0112INVALID_FILTER/ 400 envelope through the existinginvalidFilterError. No new error code and no new export. The wording followsemptyFieldConstraintError: it names the constraint and its position, and gives the two legal repairs (name an operator, or write a direct comparand). It carries the ruled reasoning that the shape means neither "every row" nor "no rows". Percheck:doc-authoring, the runtime string has no tracker number.assertPreviewCanEvaluateis the row-independent gate that runs before any row is read, and it now walks the whole tree. It tracks the path (where.$or[1].amount), so$and/$or/$notnesting cannot route around it. This includes an$orarm that a matching row would short-circuit past, and a seed draft with zero rows. (2)matchesWhere's field arm also refuses, so a direct caller gets the same answer. This mirrors the drivers, which judge the shape at every depth.Evidence
New file
src/__tests__/preview-empty-field-constraint.test.ts, 11 cases:$and,$or(the short-circuited arm),$not, deep nesting, and directmatchesWhereunder$and. Each assertscode: 'INVALID_FILTER'+status: 400, never a baretoThrow().where: {}/$and: [{}], which is the identity and not a field constraint).Reverse verification (fix committed first, at
3adb37d427):preview-evaluator.tsfrom basedabf8d795ewas written to disk, and the landing was proven bygrep -c isEmptyFieldConstraint= 0.git checkout HEAD -- pathinside a trap. It was proven by the blob hashb4aeff76faequalling HEAD and by an emptygit diff HEAD.srcby relative path, so nodistwas involved.Package runs at
3adb37d427:pnpm --filter @objectstack/service-analytics run test: 115 files / 2453 tests passed.typecheck(tsc --noEmit): exit 0.--listFilesincludes the new test file.--no-inline-config --format jsonon both touched.tsfiles: 2 files, 0 errors, 0 warnings.Derived gate families:
node scripts/pm/dispatch-gates.mjsgave 60 derived. Reconciled with--ran: 57 exit 0, 3 NOT MEASURED, 0 unrun. The NOT MEASURED ones arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt. Each exited 3 withPREREQUISITE NOT METbecause it needs the whole-repo build. That narrowing is declared and left to CI.check:where-matcher,check:doc-authoring,check:nul-bytes,check:test-source-aliasandcheck:published-filesare all green.Lint narrowing, declared. The population is read from
eslint.config.mjs: both files fall under thepackages/**/*.{ts,...}objects, and neither is ignored (0 "file ignored" warnings). The count is 2 files from the JSON output. Invariance: the config never enables type-aware linting (noparserOptions.project), so this diff cannot move any verdict on an untouched file. The repo-widepnpm lintbelongs to CI.What it costs
A drafted chart whose
wherecarries{ field: {} }now returns400 INVALID_FILTERin preview. Before, it rendered a number computed over every row, and that number changed at publish. To fix a filter, name the intended operator:{ status: { $eq: 'open' } }or{ status: 'open' }. Changeset:patchfor@objectstack/service-analytics.Acceptance notes
Datein implicit-equality position has the same enumerate-to-nothing fall-through, and this PR leaves it alone. Probe:matchesWhere({ d: 'zzz' }, { d: new Date('2026-05-01') })answerstrue. TheDatefalls into the operator-map arm, and its zero entries become a match. It is deliberately outside this refusal, because the drivers treat aDateas a comparand, not a constraint. What it should answer (an instant equality throughcompare) is not pinned, and reachability throughqueryDataset(a JSON wire body) was not established. Owner: none.Generated by Claude Code