Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): unscoped GET /api/v1/search stops 400ing when a federated object is registered and pinyin recall is on (#9469) - #9816
Conversation
…bjects (#9469) An unscoped `GET /api/v1/search` answered 400 on the stock showcase config whenever pinyin recall was on and a federated object was registered. The `__search` companion is a real column the platform promises to build: the SchemaRegistry declares it at object compile time and the driver's `syncSchema` materializes it as an additive migration (ADR-0045). On a federated object (ADR-0015) that promise cannot be kept — the remote database owns the schema, DDL is forbidden, and the schema-sync seam skips those objects. The declaration went on anyway, so the object carried a field with no column, and `expandSearchToFilter` — which keys the companion clause on the DECLARED field — ORed `{ __search: { $contains: term } }` into every `$search` against it. The backend refused the statement it could not compile (`no such column: __search` => INVALID_FILTER / 400, #8790). The unscoped call is the only global-search call that sweeps every registered object, which is why scoping hid it and why no console user was affected. The gate uses `external != null` — deliberately the same expression the schema-sync seam tests — so the seam that declares the column and the seam that would build it cannot disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…coped-search-pinyin-federated
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 5ffc22e423887a2edd0be898ecac2f66bfe1cac3 && git checkout 5ffc22e423887a2edd0be898ecac2f66bfe1cac3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cc8a3b083e05bbd7e9064b301080bd4354c901d 5e00fb9ff32a3b4322cc395b705c685e57ec37ac && git checkout -B drift-repro 5cc8a3b083e05bbd7e9064b301080bd4354c901d && git merge --no-ff 5e00fb9ff32a3b4322cc395b705c685e57ec37ac
node scripts/docs-audit/affected-docs.mjs --json 5cc8a3b083e05bbd7e9064b301080bd4354c901d
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9469
An unscoped
GET /api/v1/search— noobjects=parameter — answered 400on the stock showcase configuration. Reproduced on a real boot before
anything was changed, with the scoped query as the control:
{"error":"Filter on '__search' names a column that object 'showcase_ext_customer' has no column for, so the predicate never ran. ...","code":"INVALID_FILTER"}What the measurement found, and where it differs from the card's hypothesis
The card and the dispatch both guessed that the federated object cannot
satisfy something the recall path requires. The measurement inverts that: the
platform stamps a column onto the federated object that its datasource can
never materialize. The defect is in the producer, not in the recall path.
The
__searchcompanion is not metadata — it is a real column the platformpromises to build. The SchemaRegistry declares it at object compile time and the
driver's
syncSchemamaterializes it as an additive migration (ADR-0045). On afederated object (ADR-0015) that promise cannot be kept: the remote database
owns the schema, DDL is forbidden, and the schema-sync seam skips those objects
outright —
packages/objectql/src/plugin.ts, verbatim:The declaration went on anyway. Confirmed live against
/api/v1/meta/object/showcase_ext_customerbefore the fix — the federated object served a full
__searchfield definition.So the object carried a field with no column, and
expandSearchToFilter— whichkeys the companion clause on the declared field — ORed
{ __search: { $contains: term } }into every$searchagainst it. The backendthen refused a statement it could not compile, correctly (#8790: the predicate
really could not have run). From the server log, verbatim:
Every source-column clause was fine; only the companion named a column that does
not exist.
The defect is wider than the card describes
The unscoped call is the one that sweeps every registered object, so it is the
only global-search call that included a federated object — which is why scoping
hid it and why no console user was affected. But the same refusal was measured on
a call that never involved global search at all:
Any
$searchtouching a federated object refused. That is what decides where thefix belongs.
Which branch of the acceptance bar this delivers, and why
The bar is a disjunction: the recall path skips objects it cannot serve, or
the refusal explains itself with a route to a working call.
This PR delivers the skip branch, applied at the provisioning seam rather
than at the sweep. Three reasons, in order of weight:
The caller's request is valid, the object is searchable, and its source
columns can answer — the only broken thing is a column the platform declared
about itself. A refusal naming "a route to a working call" would be the
platform asking the caller to route around a defect in the platform.
searchAllwould fix one face of three. The listendpoint's
?search=and the scoped-to-federated call both refuse, andneither goes through the global-search sweep. Only removing the undeliverable
declaration fixes all three at once.
consumer — a
catchin the sweep, a lenient filter builder — fossilizes asecond de-facto contract in which a declared field may not exist. The producer
is where the wrong declaration is made.
The fix
One gate at the provisioning seam: an object carrying an
externalbinding getsno companion declaration.
The predicate is
external != null, deliberately the same expression theschema-sync seam already tests, not a second question about the same fact. The
two ends then agree by construction: every object the sync seam declines to build
a column for is exactly an object the provisioning seam declines to declare one
on. Asking the datasource's
schemaModehere instead would be a secondimplementation of one rule — and the SchemaRegistry holds no datasource
definitions at all (measured: zero occurrences of the word in
registry.ts), sothat drift would be structural rather than merely possible. The analytics
federation gate (ADR-0062 D6) already reads the same presence test.
A federated object whose remote table genuinely has a
__searchcolumn keepsits recall: the author declares it as an ordinary field, and provisioning returns
early on an already-present entry before reaching the new gate.
plugin-pinyin-searchneeds no change — its before-save hook and its bootbackfill both early-out on the declared field, so they become inert on federated
objects rather than newly wrong.
Verification
Live, on a stock showcase boot (
pnpm dev -- --fresh), which registers twofederated objects and turns recall on through its
zh-CNlocale. Before / afteron the same configuration:
GET /search?q=acme(unscoped)INVALID_FILTERGET /search?q=aurora(unscoped)INVALID_FILTERshowcase_ext_customer:Aurora LabsGET /search?q=acme&objects=showcase_account(control)GET /data/showcase_ext_customer?search=auroraINVALID_FILTERAurora LabsGET /search?q=hnkj(pinyin initials)INVALID_FILTER/meta/object/showcase_ext_customer__search__search/meta/object/showcase_account__search__searchAnti-vacuity is the
q=aurorarow. "Not a 400" would be worth nothing if thesweep could not have returned anything: that query returns the federated
object's own row, so the unscoped path is measured working, not merely
not-failing. The
hnkjrow is the other direction — pinyin recall still reachesthe CJK account, so the capability is bounded by this fix, not disabled.
Pin —
packages/objectql/src/global-search-federated-object-recall.test.ts,8 tests. It boots through the real registry seam with the real
OS_SEARCH_PINYIN_ENABLEDvariable rather than hand-stamping the companion,against a driver whose tables have a fixed column set and which refuses an
unresolvable WHERE column with the ADR-0112 pair (
code: INVALID_FILTER,status: 400) — the envelopedriver-sqlraises. The first test pins thatrefusal directly, so no later "did not refuse" assertion can pass against a
harness that refuses nothing.
Reverse verification, run from the committed fix and then restored from the
commit: reverting only
search-companion.tsturns the suite 4 red / 4 green,and the direction is the predicted one —
$search, the failures carryingSerialized Error: { code: 'INVALID_FILTER', status: 400 }— the same pair the live boot produced;
pin evidence about the unscoped path specifically rather than about search
generally.
Recall off (
OS_SEARCH_PINYIN_ENABLED=false) is pinned too, and it is thecard's isolation: no companion is declared on any object, the unscoped sweep
returns both objects' rows, and pinyin initials recall nothing. Both recall-off
tests stayed green under the reverse verification — i.e. they pass identically on
the defective tree, which is exactly why flipping the flag changed the outcome.
Gates
Re-derived with
node scripts/pm/dispatch-gates.mjs(no paths passed — thescript takes the change set from the merge base itself). All readings below are
from
5e00fb9, the final commit, afterorigin/mainwas merged.Green:
check:changeset-gate-self-tests,check:cross-package-test-inputs,check:durability-log-level,check:objectui-changeset,check:error-code-casing,check:nul-bytes,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-cross-package-test-inputs,check-engine-split-ratio,docs-audit/check-affected-docs,check-adr-anchors.Convention-triggered by the new test file, all green:
check:query-options-erasure,check:type-check-coverage,check:engine-double-contract,check:where-matcher, andcheck:type-check-debt --re-measure— measured, not skipped, on the builtclosure: 33 ledger entries re-measured in 319.1s, 1926 raw tsc errors total, none
above its recorded number; surplus: none. No baseline moved.
Beyond the dispatch lead:
check:changeset-gate-self-tests,check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset, plus the fourconvention-triggered gates above.
check:error-code-casingwas named in the leadbut not derived; run anyway, green.
Suites:
@objectstack/objectql219 files / 3873 tests pass (typecheck clean);consumer direction —
plugin-pinyin-search14,rest's companion agreement 6,metadata-protocol1684 — all pass.Clause-②:
noDeclared here rather than inherited. The change narrows what the platform
declares about itself (a federated object stops advertising a
__searchfield itcould never serve) and accepts no request shape the API rejects today. A request
that returned 400 now returns results; no accept set widens, no contract grows.
This restores a published surface.
ADR anchor
scripts/adr-anchors/packages__objectql__src__search-companion.ts.json— the newgate would read as arbitrary to someone in
search-companion.tsalone ("why doesa search module care about federation?"). ADR-0015 is the decision it stands on.
Generated by Claude Code