You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(rest): lower public-picker filter rules to the grammar the ingress parses (#16673)
* fix(rest): lower public-picker filter rules to the grammar the ingress parses
`GET /forms/:slug/lookup/:field` composed its filter list out of
`ViewFilterRule` objects — the `{ field, operator, value }` dialect
`FormFieldPublicPickerSchema.filter` declares — and put them on the
`findData` filter slot, which accepts a `FilterCondition` or a
`FilterArray` and refuses everything else with `400 INVALID_FILTER`. The
`q` branch builds the same object shape itself, so every non-empty
search was refused whether or not an author declared
`publicPicker.filter`; only the degenerate empty-filter call succeeded.
The route now lowers the composed rows to the array grammar, reusing
`normalizeFilterOperator` from `@objectstack/spec/ui` rather than
restating the alias table. A row that cannot be read as a rule is
forwarded verbatim so the ingress still refuses the request — the
fail-closed direction on an anonymous surface, where the picker's static
filter is what bounds what a visitor can search.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
* fix(rest): read `records` from the picker's findData result
The public lookup handler read `result.data ?? result.items` and never
`result.records`, which is the key `findData` returns
(`{ object, records, total, hasMore }`) and the order this file's three
other read sites already use. With the filter lowered the route
therefore answered `200 {"data":[]}` — an empty picker for every search,
the same user-visible outcome as the 400 by a different route.
Bounded in-place: same handler, same defect class (the route speaking a
shape the protocol layer does not), and the acceptance for #16581 is
"200 with the RIGHT ROWS", which is unreachable without it. The legacy
`data` / `items` / `rows` / bare-array aliases stay so protocol doubles
and alternate protocols keep working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
* test(rest): pin the new picker doubles and hold the caller's bound
`check:engine-double-contract` and `check:objectql-double-limit` both
name the new suite: the metadata-write double's delete/findOne/update
seams were not in the pinned ledger (regenerated with `--write`, three
additive rows, no losses), and the data double returned its filtered
rows without applying `options.limit`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
* docs(rest): the picker matcher implements three comparisons, not two
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
`GET /forms/:slug/lookup/:field` answers a search again: the public-form lookup picker no longer refuses every non-empty query with `400 INVALID_FILTER`.
6
+
7
+
The route composed its filter list out of `ViewFilterRule` objects — the `{ field, operator, value }` dialect `FormFieldPublicPickerSchema.filter` declares in so many words ("Same `{ field, operator, value }` dialect as list-view filters") — and put them straight onto the `findData` filter slot. That slot accepts a `FilterCondition` object or a `FilterArray` (`[field, operator, value]`, a logical node, or a list of those) and refuses anything else. The refusal did not depend on an author declaring `publicPicker.filter`: the route's own `q` predicate is built in the same object shape, so **every** non-empty search was refused and only the degenerate empty-filter call could succeed — on an anonymous surface where a public-form applicant has no way around it.
8
+
9
+
-**The route lowers; the parser is untouched.** The composed rows are translated to the array grammar the ingress parses, at the one door that speaks both dialects. ⛔ The repair deliberately NOT taken is teaching `findData` a second dialect: that maintains two filter grammars in the data layer permanently and spreads the object shape to every `findData` caller. The declaration already promises the object dialect on the authoring surface, so what changes is the side that failed to honour the promise. A test keeps the control that the object shape fed to the parser directly is still refused, so "the route lowers" cannot be confused with "the parser was loosened".
10
+
-**Both branches.** The declared `publicPicker.filter` rows and the route's own `contains` search row are lowered together and ANDed explicitly; no declared filter still means no filter (`[]`), never an empty logical node the ingress would refuse.
11
+
-**The operator fold is the spec's own.** Lowering reuses `normalizeFilterOperator` from `@objectstack/spec/ui` — the fold `ViewFilterRuleSchema.operator` itself runs — so a stored row carrying a legacy spelling (`notEquals`, `isNotEmpty`, `gt`) folds exactly as the schema folds it. No second alias table.
12
+
-**A rule that cannot be read is forwarded, not dropped.** The request is then refused exactly as before. That direction is deliberate: a picker's static filter is often the only thing keeping an anonymous visitor's search inside the rows a form may expose, and silently skipping a row nobody understood would answer 200 over an unfiltered table.
13
+
14
+
No authoring surface moves: `FormFieldPublicPickerSchema` already declared this dialect as accepted, and this makes the runtime honour it.
0 commit comments