fix(data): gate unknown fields on the explicit filter axes (#7534) - #7587
Conversation
`POST /data/:object/query` with `{"where":{"not_a_field":"x"}}` answered
`200 {records:[],total:0}` — and identically through the `$filter` door and
the filter-AST door — while the bare-key door on the same object and the same
field name answered `400 INVALID_FIELD`. One endpoint family, two verdicts for
one mistake, and the losing one is indistinguishable from "no data".
Not a regression of #4134: the bare-key control still passes at the branch
point (measured alongside the three failures). It is the sibling door that
fix never reached — `assertQueryParamsAreFields` gated only the implicit
filters derived from leftover query params, while the explicit axes reached
the driver ungated.
`assertFilterFieldsExist` calls the existing `resolveQueryFields` — additively;
that shared helper is unchanged — on the normalized `where`. One call covers
all three doors because they fold to one slot (#3795) and the AST is lowered
by `parseFilterAST` before the gate runs, so it reads the same
`FilterCondition` the driver reads.
Ordering is deliberately unmoved: after the #4134 param gate (so existing
precedence holds) and before the #4164 merge (so the rejection can name the
axis the caller used).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxE7c6qf7Bi9ZQ7HtYrNUj
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
… gate The gate sits at the `findData` ingress, so it also reaches the record-matching lookup the import runner performs for `update` / `upsert` writes. That is a user-visible behaviour change on a second surface, and someone who hits it must be able to find out why from the release notes rather than from a support thread. Records the old behaviour (a `matchField` naming no field silently degraded an upsert into an insert, returning `'none'`), the new one (that row fails with `400 INVALID_FIELD`, contained by the row loop's own try/catch so the rest of the import proceeds), the remedy, and that `resolveRef`'s speculative probes are unaffected because they already catch the absent-field case deliberately. Ruled on #7534: the failure stays. Exempting the import path would have meant ADDING code to preserve a silent data-correctness bug of the same family this change closes. Changeset only — no code changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxE7c6qf7Bi9ZQ7HtYrNUj
Fixes #7534
What was wrong
POST /api/v1/data/:object/querywith{"where":{"not_a_field":"x"}}answered200 {"records":[],"total":0}— nocode, no mention of the unknown name — and identically through the$filterdoor and the filter-AST door. The bare-key door, on the same object and the same field name, answered400 INVALID_FIELD.One endpoint family, two verdicts for one mistake, chosen by which door the caller used — and the losing verdict is indistinguishable from "no data".
This is not a regression of #4134 — verified at the branch point
Measured on the branch point (
9051802) before writing any fix, all four doors on one object and one field name:The control holds, so #4134's gate is intact on the door it covers. This is the sibling door its fix never reached:
assertQueryParamsAreFieldsgated only the implicit filtersfindDataderives from leftover query params, while the explicit axes reached the driver ungated — even thoughresolveQueryFieldsdocuments itself as "ONE resolution shared by all four read axes".What landed
assertFilterFieldsExistcalls the existingresolveQueryFieldson the normalizedwhere.resolveQueryFieldsitself is unchanged — the call is purely additive, per the serialization constraint with #7532 on the projection axis.One call covers all three doors because they are not three code paths:
where/filter/filters/$filterresolve to one slot at the #3795 fold, and a filter AST is lowered byparseFilterAST— the single sink for that sugar — before the gate runs. The gate therefore reads the sameFilterConditionthe driver will read.Rejections carry the envelope the write path and the bare-key door already produce —
400 INVALID_FIELD+field+fields+object— plusparamnaming the caller's own wire spelling ($filter, notwhere), and a message stating the zero-row consequence.File surface
packages/metadata-protocol/src/protocol.tsFILTER_LOGICAL_KEYS(+1 const) andcollectFilterFieldKeys(module scope, +~30 LoC) — walk aFilterConditionfor field-naming keyspackages/metadata-protocol/src/protocol.tsassertFilterFieldsExist(new private method, beside its #4134 sibling) — the gate, calling the unchangedresolveQueryFieldspackages/metadata-protocol/src/protocol.tsfindData— one added call, after the #4134 param gate, before the #4164 mergepackages/objectql/src/protocol-explicit-filter-field-gate.test.tsGUARD).changeset/rest-list-explicit-filter-unknown-field.md@objectstack/metadata-protocolNo other file changed.
resolveQueryFields,assertQueryParamsAreFieldsandassertProjectionFieldsExistare byte-identical tomain.Ordering, deliberately unmoved
?pageSize=5返回 200 + 空列表 #4134 param gate, so a request that gets both a bare key and its filter wrong answers exactly as it did before (pinned by aGUARD).filter在场时,同时传的字段级参数被静默丢弃(#4134 的邻居) #4164 implicit/explicit merge, which is what still lets the rejection name the axis the caller used — after the merge both are one$andand the distinction is gone.Reach, deliberately bounded
$and/$or/$notare recursed into.{$gte: 18}) or a nested-relation condition ({owner_id: {region: 'NA'}}) whose keys belong to a different object. Judging those against this object's field map would refuse legitimate relation filters.owner_id.name.$-combinator is skipped without descending: a hole, not a false rejection. Right failure direction for a gate that exists to stop wrong answers.200+total: 0. A filter that cannot be run is stillINVALID_FILTER(A filter array that isn't a valid AST reaches the driver as an opaquewhere— reject it at the protocol instead #4121 / REST 列表:无法解析的filterJSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员) #4181), which answers first.Reverse verification
Predictions were written before running. The revert was performed by neutralising the gate body (not the call site — removing the call fails the build with
TS6133, which would have made the whole revert run read against a staledist; the first attempt did exactly that and its greens were discarded).where objectdoor refuses unknown field$filter stringdoor refuses unknown fieldfilter ASTdoor refuses unknown field$and/$or/$notparamnames caller's own wire spellingGUARDbaseline — no filter returns every rowGUARDevery explicit door applies a REAL filterGUARDbare-key control untouched (#4134)GUARDreal field matching nothing = honest 200/0GUARDregistry-injected system fields filterableGUARDdotted path on REAL head passes throughGUARDnested-relation value not descended intoGUARDunknown object stays 404GUARDunrunnable filter stillINVALID_FILTERGUARD#4164 composition unchangedGUARDparam gate reports first when both wrongMissed prediction (disclosed). One test — the dotted-path case — was labelled
GUARDand went RED on revert. Cause: I bundled two assertions into one case, a genuinely both-directions-green half (owner_id.namepasses through) and a fix-dependent half (not_a_field.name→ 400). The label was wrong, not the test. Split into two cases with the correct labels; the table above reflects the corrected split, and the re-run confirms 11 RED / 11 green.Fixture bug caught by a failed green prediction.
GUARD every explicit door applies a REAL filterfailed on the first run. The door table built its "real field" variant byJSON.stringify(…).replace(/"x"/g, …), which silently misses on the$filterdoor because that door nests JSON inside JSON and its quotes are escaped. Per the working rule, I suspected the fixture rather than the gate — the door table now takes the value as a parameter instead of patching it in by string surgery, and the reason is written into the file so it is not reintroduced.Anti-vacuous. The positive identity is pinned first: every door is shown to apply a real filter and return the two expected rows by id before any refusal is asserted. The four-door agreement test builds an explicit verdict list and asserts
toEqualon all four strings, so a door answering something new — including a different wrong answer — fails. Both loop-based assertions carry atoHaveLengthscale guard.Consumption radius
Grepped every
findDatacaller repo-wide and ran the suites, since grep alone misses fragment- and regex-shaped pins.@objectstack/metadata-protocol@objectstack/objectql@objectstack/rest@objectstack/runtime@objectstack/clientturbo run typecheck(both changed packages)eslint --no-inline-configon changed filesturbo run test --concurrency=3One downstream behaviour change, disclosed
import-runner.tshas two speculativefindDataprobes:resolveRefprobes candidate display fields (name,title,label, …) that may not exist on the target object. It already wraps them incatch { /* field absent on target object — try the next candidate */ }, so a 400 lands exactly where the 200/0 did. No change.findExistingfilters by the caller's configuredmatchFields. AmatchFieldnaming a nonexistent field previously returned'none'and silently degraded an upsert into an insert; it now produces a per-row failed result carryingINVALID_FIELD(the row loop's owntry/catchcontains it). This is contained and, I'd argue, the correct direction — but it is a behaviour change and no test pinned either way.Not owed
No spec migration entry: #4134 / #4226 / #4254 — the sibling gates at this same REST-ingress layer — have none, and the one semantic entry in this family (#7095) exists because it changed the engine code-path API, which this does not.
content/docs/releases/untouched.What I did not measure
SqlDriver/ better-sqlite3) over HTTP. I measured at thefindDataingress with a realObjectQLengine and registry — the layer the gate lives at, and the layer all four doors converge on — not through an HTTP socket. The@objectstack/restsuite covers the route→mapDataError→envelope leg, but I did not personally observe400 INVALID_FIELDon the wire against the showcase invoice object.engine.find), but I exercised it only against the in-suite stub driver and whatever the package suites use.$norand other unrecognised combinators are documented as an intentional hole, not tested as one.Generated by Claude Code