Uh oh!
There was an error while loading. Please reload this page.
fix(example-showcase): make the react rollups use the adapter's query contract - #10481
Conversation
… contract (#10288) `renewals-pipeline` passed `top: 500` and `crm-workbench` passed `limit: 200` to `adapter.find`. Neither is a query option. `QueryParams` (objectui packages/types/src/data.ts) declares only `$`-prefixed keys and `ObjectStackAdapter.convertQueryParams` (objectui packages/data-objectstack/src/index.ts) builds its outgoing options by copying exactly those, so the key reached no branch and was dropped with no warning. The consequence is the inverse of the filed one: the GET list route has NO default page size (packages/client/src/index.ts, pinned in packages/client/src/client.test.ts and measured by objectql's `baseline - no params returns every row`), so an absent `top` returns the ENTIRE match set. The cap the author wrote simply never happened. The same effect then read its rows off `.records`. `find()` resolves to a normalized `QueryResult` -- `data` plus `total`, never the REST envelope -- so `pr.records` was `undefined` on every call and the renewals KPI strip sat at 0/0/0 while the `<ListView>` beside it showed the same rows correctly. `crm-workbench` carries a comment about exactly that defect, fixed there and still live here. Applying the cap is only half a fix: `data.length` under a `$top` IS the silently-capped count the card was filed about. Both pages now count the envelope's `total` -- the server's real count over the same `$filter` whenever a limit was applied. "Open AR" is a per-row verdict over the fetched window, the one number a cap genuinely bounds, so it renders as `100+` rather than passing for a total. `test/react-page-adapter-query-contract.test.ts` executes the page's real rollup effect against a contract-faithful adapter double and then sweeps every `kind:'react'` page in the app for both contracts, with an extraction control, a census control and a positive control on the scanners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
os-zhuang
commented
Aug 21, 2026
PM review — ⭐ accepted. The premise correction is the whole value here, and it changed the fix.Reviewed against the diff. ⭐ You inverted the card's consequence, and the inversion matteredThe card said a dropped And you found a second defect the card never mentions: the effect read rows off ⭐⭐ The judgement that makes this PR right rather than merely correct
That is the finding. The obvious two-character fix — ⭐ And the ablation observed the direction rather than assuming it: flipping What I verified
⭐ The gate ruling is the right call, for the right reason"No new gate in objectstack" backed by four measurements, and the decisive one is architectural rather than economic:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10288
What the card asked for, and what turned out to be true
The card is right that
topis not a query option:QueryParams(objectuipackages/types/src/data.ts) declares only$-prefixed keys, andObjectStackAdapter.convertQueryParams(objectuipackages/data-objectstack/src/index.ts) builds its outgoing options by copying exactlythose, so a bare
top:reaches no branch and is dropped with no error.Its consequence is the inverse of the filed one, and the correction is what shaped
this PR. The card says the queries "return the backend's default page size instead of up
to 500 rows", producing a silently capped KPI. There is no default page size. From this
repo's own sources:
packages/client/src/index.ts— "The GET list route has no default page size, so anabsent
topreturns the ENTIRE match set: the caller who asked for no records gotevery record, under a 200 with no warning." Pinned in
packages/client/src/client.test.ts([finding][client]
data.find's pagination params are emitted on truthiness, so{ limit: 0 }is dropped exactly like thelimitbug #6322 fixed #6485).packages/objectql/src/protocol-unknown-query-param.test.ts—baseline — no params returns every row, over a realObjectQLengine and a real protocol: 10 rows seeded,total: 10with no params. Re-run on this branch, green.So the dropped key did not truncate the read — it removed the cap. The page was fetching
every related row for the selected account, unbounded.
The KPI strip was wrong for a second, separate reason the card does not mention, and
it is the one a reader sees: the effect read its rows off
pr.records.find()resolvesto a normalized
QueryResult—data,total,page,pageSize,hasMore, never theREST envelope's
records— sopr.recordswasundefinedon every call and the stripread 0 / 0 / 0, not a capped number. That defect was already found and fixed once on
the sibling page:
crm-workbench.page.tscarries the diagnosis in a comment ("Reading.records here always missed, so the KPI cards silently stuck at 0 even though the
ListView beside them showed the same rows") and was still live here.
The harm, reproduced
The real page source, lifted out of the page file and executed against a
contract-faithful adapter double, over an account holding 640 projects and 640 invoices
(128 of them open):
Why this is not a two-character edit
$top: 500alone would have created the defect the card describes. With a capapplied,
data.lengthis a page length, so the "Projects" and "Invoices" KPIs would havesilently reported 500 for every account above the cap — a capped count reading as a real
number, which is the card's own words for the harm. Both pages therefore count the
envelope's
total, which with a limit present is the server's real count over the same$filter(ObjectStackProtocolImplementation.findDatarunsengine.counton thatpath).
"Open AR" is a per-row verdict and genuinely cannot be exact over a capped window — it is
the one number the cap really does bound. It renders as
100+when the window wastruncated rather than passing for a total.
Changed
examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts—$topon both calls,rows off
.data, counts offtotal,cappedsurfaced in the Open AR stat, and anumbered note on the effect stating both contracts (this page is the copy-paste
surface, so the comment is load-bearing).
examples/app-showcase/src/ui/pages/crm-workbench.page.ts— same class, found by thesweep:
{ limit: 200 }→{ $top: 200 }, headline count offtotal.examples/app-showcase/test/react-page-adapter-query-contract.test.ts— new.Sweep census
adapter.find/dataSource.findcall sites, params-object keys extracted by bracematching, classified by whether the file's adapter comes from
useAdapter()(theobjectui
DataAdapter) or from something else.Positive control: the scanner was run first against
origin/mainand reported theknown-present
renewals-pipeline.page.ts:67and:68top: 500before it was used toclaim anything absent.
In this repo, 3 unprefixed-key sites in 2 files, both under
examples/**, both fixedhere:
examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts:67topexamples/app-showcase/src/ui/pages/renewals-pipeline.page.ts:68topexamples/app-showcase/src/ui/pages/crm-workbench.page.ts:39limit21 further key hits across 5 files are a different
adapter:plugin-auth'sbetter-auth adapter (
findOne({ model, where }), 4 files) and aPostgresAdapterincontent/blog/protocol-first-development.mdx. Neither is the objectuiDataAdapter;model/where/fieldare correct there..recordsreads off afind()result, same sweep: 4 sites — the two repaired here, onebenign (
crm-workbenchalready read.datafirst), andcontent/docs/ui/react-pages.mdx:147,which is reported separately (below).
Sweeping objectui at
c40f3b8found 4 more unprefixed-key sites plus tworecords-only reads. Outside this repo's file surface — reported, not touched.The gate question: not built here, and the measurement
Ruling: no new
os validaterule orcheck:*gate in this repo. A scoped regressionpin instead.
What a gate would have to cover, measured rather than guessed:
examples/**, 0 after this PR.There are exactly 3
kind:'react'pages inexamples/**and oneuseAdaptersamplein
content/docs/**. That is the entire population an objectstack-side checker couldever read, because a react page's
sourceis a string — ESLint cannot see into it, andtsccannot either.instances by different authors:
object-timeline(objectui#4009 / objectstack#7137),object-kanban(objectui#4025),crm-workbench's.records(repaired in place,comment still there),
renewals-pipeline(this card), and the four live objectui sitesthe sweep just found. Four of the five live in objectui.
eslint-rules/no-query-params-under-options.jsbans a
$-prefixed key nested underoptions, and its header makes the case formechanising this family: the mistake type-checks, it publishes, and "a review catches it
once and then misses the next one." It does not look at a bare top-level key — that is
the half with the live sites.
QueryParamsis objectui's contract. A checker in objectstackwould have to hard-code another repo's key list and become a second source of truth for
it — the exact second-de-facto-contract shape Prime Directive Add comprehensive test suite for Zod schema validation #12 warns about, on a
contract this repo does not own and cannot keep in step. The rule belongs beside the
type it enforces, next to the rule that already gates the other half. Reported as
[objectui] four live
adapter.findcalls pass an unprefixed query option — andno-query-params-under-optionsgates only the sibling half of the class #10470 with the census and a suggested rule shape.What is built instead, at ~50 lines and no new infrastructure:
test/react-page-adapter-query-contract.test.tssweeps everykind:'react'page thisapp ships for both contracts. It needs no key table — the assertion is the prefix rule
itself ("every key the page sends starts with
$") plus "rows come off.data" — so itduplicates no contract and closes the whole class for this repo's file surface. It carries
three controls: an extraction control (the lifted effect really contains both
findcalls), a census control (the page list is non-empty and contains the renewals page), and
a positive control (both scanners fire on a known-bad synthetic source, and the
comment-skip does not silence a real read).
Reported separately, out of scope here
result.records, which is always undefined — and its Callout misstates what a dropped option does #10469 —content/docs/ui/react-pages.mdx: the live-data sample readsresult.records(alwaysundefined, so the documented sample renders an empty list),and its Callout claims a dropped
top:yields "the default page size". Left alone here:content/docs/**pulls in a different gate family than this diff(
check:doc-anchors,check:docs-audit-scope,check:docs-redirects,check:published-readme-links,check:role-word, plus the{/* os:check */}convention), and the Callout needs a wording decision.
adapter.findcalls pass an unprefixed query option — andno-query-params-under-optionsgates only the sibling half of the class #10470 —[objectui]the four live sites and the ESLint-rule extension.Ablations
The fix was committed first, then broken deliberately, each leg confirmed on disk by
counting the removed anchor and the injected text (a first attempt with
perl -0pimatched nothing and produced
$toptop:— caught by that count, not by an exit code).$top: 500→top: 500(anchor 2 → 0, injection 0 → 2): 3 tests red —expected false to be trueon the all-keys-$-prefixed assertion,expected 128 to be 100on Open AR (the unbounded read returning all 128 open invoices instead of thehonest 100 inside the window), and the page sweep reporting
{key: 'top', snippet: "{ $filter: […], top: 500 }"}. Note the direction: the ablation shows a missing cap,not a truncation.
(res && res.data)→(res && res.records)(anchor 1 → 0, injection 0 → 1):3 tests red —
expected +0 to be 100, the in-window case losingcapped: false,and the
reads rows off QueryResult.datasweep firing.git diff HEADempty, tree byte-identical to the fix commit.Verification
Gates re-derived from the real diff with
node scripts/pm/dispatch-gates.mjs(no pathspassed — it takes its own change set from the merge base), run at
fde5f1e:check:nul-bytesOK (scanned 6140 text file(s) … no raw ASCII control bytes)check:changeset-gate-self-testscheck:objectui-changesetobjectui-range --self-test: all checks passedcheck-adr-0087-registration.mjsthis PR adds no declared-breaking changesetcheck-changeset-no-major.mjsThis diff introduces no 'major' bumpcheck-empty-changeset.mjsNo empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declaredcheck:engine-double-contractOK — 340 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher266 matcher(s) discovered … none newcheck:query-options-erasureratchet holds: 67 unswept non-test site(s) … none new; test surface240 site(s) in 47 file(s) — at the ceilingcheck:type-check-coverageOK — 64/77 workspace packages type-checkedcheck:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 352.5s, 1924 raw tsc error(s) total, none above its recorded number— full closure built first, exactly aslint.ymldoesPackage-level, at the same commit:
pnpm --filter @objectstack/example-showcase test—Test Files 23 passed (23),Tests 361 passed (361).pnpm --filter @objectstack/example-showcase typecheck— clean (this package'stsconfig.jsonincludestest/**/*, so the new test file is in the program).pnpm --filter @objectstack/example-showcase validate— exit 0, no new findings,confirming the card's point that
os validatecannot see this class.Changeset
examples/app-showcaseisprivate: true, but this repo's.changeset/config.jsonsetsprivatePackages: { version: true, tag: false }, so it is versioned and carries its ownCHANGELOG.md— and prior showcase-only PRs wrote real changesets for it. A changeset,not the
skip-changesetlabel.Generated by Claude Code