fix(objectql)!: a per-aggregation filter and having refuse a non-boolean $exists / $null with INVALID_FILTER / 400, in the drivers' words (#20981) - #21157
Conversation
… $exists / $null (wip) Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
… changeset (wip) Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…g-flag-comparands
…lag-comparand cell Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…g-flag-comparands
📓 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 — 17 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 3a129916f67e4877849e656a043540b75b65c039 && git checkout 3a129916f67e4877849e656a043540b75b65c039
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 70dae533c58a60a7009d253cf73f87c3a5848d95 704a58ab232e0c7afe425b5df41518cd4151e3a5 && git checkout -B drift-repro 70dae533c58a60a7009d253cf73f87c3a5848d95 && git merge --no-ff 704a58ab232e0c7afe425b5df41518cd4151e3a5
node scripts/docs-audit/affected-docs.mjs --json 70dae533c58a60a7009d253cf73f87c3a5848d95 |
Contract reviewServed-tier: ① Derived judgmentsInputs: card #20981 (body; comments 5923295010, 5926372178, 5927784503, 5929772241, 5929818992), PR #21157 (body, the 6-file list, the net diff from merge-base
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20981
Clause-②: no (narrowing)
The engine's in-process evaluator for a per-aggregation
filterand ahavingnow refuses a non-boolean$exists/$nullwithINVALID_FILTER/ 400, in the words every driver'swhererefuses it in. It is judged once, before any driver is asked for a row, and per row as the floor, beside the existing$emptygate.true/falseanswer exactly as before.What was wrong
Measured on
origin/main7a606a9a3throughengine.aggregateon driver-memory AND driver-sql (better-sqlite3). The object has a text fieldname: rowaholds"won"(amount 10),bholds null (amount 1), andchas no value (amount 100). The two drivers answered identically, because the engine evaluates both clauses itself:$exists$null"yes"/1/"false"0/null"yes"/1/"false"wongroup0/nulltruewonfalsewoncheckConditionread$existsas!!target, and its$nullarm tested only=== true/=== false, so any other value constrained nothing. After this PR, every non-boolean cell above isINVALID_FILTER/ 400 on both drivers, and thetrue/falserows are unchanged (re-measured with the same scratch harness at the head).Reach.
POST /api/v1/data/:object/querynever reached the defect. The route parses the request against the spec's query schema first, and that parse already refuses a non-boolean flag inwhere, inaggregations[i].filterand inhavingwith 400VALIDATION_FAILED. This was measured on the same base and is unchanged here. The engine refusal is the floor for every caller that reachesengine.aggregatewithout that parse: server-side code, a flow or hook, the analytics bridge that lowers a measure'sfilterinto an aggregation filter, and a host callingapplyInMemoryAggregationdirectly.The change (
packages/objectql/src/having-filter.ts)nonBooleanFlagComparandError(op, field, value, path)sits besideemptyFlagComparandError.assertConditionIsEvaluablecalls it next to the$emptygate, soassertHavingIsEvaluableandassertAggregationFilterIsEvaluable(the gatesengine.aggregatecalls before any driver read) refuse at every depth, a branch a$orwould short-circuit included.checkConditionraises it above the no-value exit as the per-row floor, as$emptydoes.$existsis "has a value" and$nullis its mirror. No truthiness reading and no drop.engine.tsandin-memory-aggregation.tsare not touched: both already route through the walker.{ $field }in a flag's slot is now refused as a non-boolean first, as$empty's gate already does, rather than by the reference-position refusal. The code and status are the same (INVALID_FILTER/ 400), and the two existing reference rows that assert only the path stay green, with a note added. A plain object orundefinedis still refused first by the comparand-type door, in its own words. An array in the flag's slot reached the old!!targetread and is now refused.The words, and where they live
The text is
driver-sql's ownnonBooleanExistsComparandError/nonBooleanNullComparandErrordiagnostic, which is also the textdriver-memoryanddriver-mongodbput on the wire. Its "this driver" clause is re-aimed atdriver-sql, asdriver-memory's copy re-aims it. No new wording.The text has no importable home. Measured:
@objectstack/coreexports nothing for it. The spec's save door spells its own message (nonBooleanFlagComparandMessage, not exported, with different words). Every face spells its own copy: driver-sql, driver-memory, driver-mongodb, Turso's remote transport, service-analytics twice, and the spec save door. This package cannot depend on a driver. So this is a declared verbatim copy, not a silent one. The drivers'describeFilterOperand/safeShapePreviewrendering is copied too (describeFlagOperand), and the copy is held to its source bypackages/rest's new cell. That cell reads thewheretwin's withheld diagnostic off the errordriver-sqlthrows (withheldFilterDiagnosticOf) and requires the engine's message to equal it under exactly two edits: the location re-rooted at the clause, and "this driver" re-aimed at driver-sql. A change to either side breaks that equality. Whether the sentence should get a shared@objectstack/corehome (the shape #21007 gave its JSON-column sentence) is raised to the PM as an open question in the report on #20981, and is not decided here.Disclosure:
driver-sqlwithholds the field and the value from awhererefusal, because awherecan carry a merged read scope. A per-aggregationfilterand ahavingnever do, so this message names both, as this face's$emptyand$icontainsrefusals already do.Pins
packages/objectql/src/engine-aggregate-flag-comparand-refusal.test.ts(new, 39 tests). Throughengine.aggregate, over a driver of eachhavingpath's shape:rows, with noaggregate(), the face that evaluatesaggregations[i].filter; andnative, where the driver aggregates and the engine applieshaving. Each of"yes",1,"false",0,null(plus a list) under both flags is refused in the filter and inhaving, on an empty and a populated table, with no driver read. Each case assertscodeINVALID_FILTER,status400, the drivers' first three sentences (operator, field, the rendered comparand, the position, the declaration) and the reason. Four more positions are covered:$and, a$orbranch after one that holds,$not, and beside a boolean sibling flag. A{ $field }in the slot is refused as a non-boolean. Thetrue/falsecontrol gives the sums and groups in the table above, on both paths. The publishedapplyInMemoryAggregation(rows, ast, tz, fields), plusapplyHavingandmatchesAggregationFilter, refuse a row that reaches the flag, with and without a value.packages/rest/src/aggregation-flag-comparand-refusal.test.ts(new, 15 tests). This is the realSqlDriver(SQLite) cell, the declareddomain:clitest-only touch. The refusal cases run onengine.aggregate, beside thewheretwin's diagnostic (the word parity above). One case records that the route refuses all three positions in its own parse first: 400VALIDATION_FAILED, asserted by status, code and field path only. Thetrue/falsecontrol runs throughPOST /api/v1/data/:object/query, and the filter's sum and the kept group equal thewheretwin's.check:driver-memory-census). Its cell is therefore the engine-levelrowsshape above, as [finding] a per-aggregationfilterwith$containson a multiple lookup counts 0 on every driver while the samewherefinds the rows: the engine's aggregation evaluator never matches a stored array #20873 and [finding] a per-aggregationfilter$ninon a multi-valued field counts the rows it was asked to exclude, and$incounts none, where the samewhereis refused 400: the aggregation evaluator has no JSON-column equality gate #21007 did, and the measurement table above was taken on the realInMemoryDriver.Pin sweep. I ran a repo-wide grep for a non-boolean
$exists/$nullliteral in tests and for both refusal sentences. Every other hit pins a driver's, the analytics door's or the save door's own face, and none asserts the engine's old answer. TwoREFERENCE_REFUSEDrows (engine-aggregate-filter.test.ts,engine-aggregate-having-comparand-shape.test.ts) now meet the flag gate first. They assert the path only and stay green, and each has a comment pointing at the new file.rest-4xx-message-truncation.test.tscarries a copy of driver-sql's$nulltext for a truncation fixture. It is not a pin on this face and is not touched.Verification (final head
704a58ab2, after mergingorigin/maintwice)pnpm --filter @objectstack/objectql test: 358 files, 7051 tests passed (post-merge,a876bc2af; the second merge brought no objectql, rest, core, formula, driver or specdata/change).pnpm --filter @objectstack/objectql typecheck: exit 0. The new test file is intsconfig.test.json's program (--listFiles: 1 hit; 0 intsconfig.json's, which excludes tests), andcheck:test-typecheckis OK.704a58ab2: the four objectql files around this change (302 tests) and the rest cell (15 tests) passed, after rebuilding the closure.packages/rest:tsc --noEmitandcheck:test-typecheckOK. The 12 aggregation and flag-related rest files (aggregation-*,rest-aggregate-*,data-query-having-temporal-door, and the three other files naming a flag operator) passed atfaafb51d3, before the merges: 200 passed, 158 skipped (the env-gated PostgreSQL / MySQL cells). The whole rest suite is declared to CI.scripts/ablation-replace.mjs. Each mutation was proven on disk (anchor count and blob change), and each restore was proven by blob equal to HEAD withgit diff HEADempty:dist/: a string marker was planted, objectql rebuilt, andablation-dist-preflightfound the marker in 4 built files. Result: 10 failed / 5 passed (the 10 refusal cases). The leg was then restored and rebuilt; preflight--absentreported the marker absent from all 14 built files and a clean tree, and the cell passed 15/15.node scripts/check-driver-conformance.mjs): 50 covered, 0 DEBT, 0 exempt, before and after.node scripts/pm/dispatch-gates.mjs --commandsafter the final commit derived 63 families from the 6 changed paths.--ranreconciles them as 63 accounted for: 62 run and exit 0, 1 NOT MEASURED. The NOT MEASURED one ischeck:dual-build-cjs-loads(exit 3, prerequisite: it reads every package'sdist/, and this tree builds only the closures).check:dts-closurefirst read red on two partially built packages,organizationsandplugin-webhooks. Their partialdist/files were written inside the two windows in which my own 560-second timeout killedcheck:type-check-debt. Both were rebuilt, and the gate then swept 61 built packages with 153/153 declaration files present.check:type-check-debtthen ran to completion: exit 0, in 232 s.eslint --no-inline-config --format jsonover the five touched TypeScript files reported 5 files, 0 errors and 0 warnings. These files are in the config's own population (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTEDandpackages/spec/**). The config never enables type-aware linting (noparserOptions.project), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.Acceptance notes
filter.zod.ts("Every query face refuses a non-boolean$null/$exists"), the save-door refusal text infilter-save-door-refusals.ts, and migration entry18.filter-query-face-comparands-refused-at-save. No spec edit.@objectstack/formula'smatchesFilterCondition, the RLS write-checkevaluator, still answers a non-boolean flag (v === true ? … : …). By the repo's own vocabulary it is not a query face: its$emptydocblock contrasts itself with "the spec's save door and every query face". Observation only, not filed: no public door was measured for it.@objectstack/objectqlminor, BREAKING, with the ADR-0087 dispositionnot-required (already-registered filter-query-face-comparands-refused-at-save). That registered entry's surface names a queryhaving, the aggregate call'shavingand an aggregation filter, and its replacement is this change's whole migration. fix(driver-memory,driver-mongodb): refuse a non-boolean $exists comparand with INVALID_FILTER / 400, as $null's is refused (#20897) #20979 used the same disposition for the drivers' half.turbobump to 2.11.5 thatorigin/mainbrought in this window writes a managed "agent guidance" block into the rootAGENTS.mdbefore repo-scoped commands when it detects an agent. That happened here on everyturbo runand oncheck:type-check-debt. It dirties every agent worktree (+11 lines), reddenscheck:pm-skill-ratchetlocally (1119 lines against a ceiling of 1116), and puts a governed-surface edit onegit add -Aaway. Here it was restored fromHEADeach time and is not in this diff. Raised in the report on [finding] the aggregationfilterandhavingread a non-boolean$existsby truthiness and DROP a non-boolean$null, on every driver: the engine evaluates both in-process, and its gate refuses only$empty#20981 for the seat to route.engine.ts,in-memory-aggregation.ts, every driver, the spec and formula.Generated by Claude Code