Skip to content

fix(app-shell,plugin-dashboard,console): send query options with their $, and gate the shape - #5946

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-5458-unprefixed-query-options
Aug 24, 2026
Merged

fix(app-shell,plugin-dashboard,console): send query options with their $, and gate the shape#5946
os-warren merged 1 commit into
mainfrom
claude/issue-5458-unprefixed-query-options

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #5458

Verified at 3a683ea — every run quoted below is that tree.

The class

QueryParams declares every query option $-prefixed and ObjectStackAdapter.convertQueryParams builds its outgoing options by copying exactly those keys. Any other key reaches no branch and is dropped: no throw, no warning. It type-checks because QueryParams carries [key: string]: any, deliberately, so adapters can take adapter-specific params.

For a dropped cap the consequence is an unbounded read, not a truncated one. Confirmed against the pinned client rather than assumed — @objectstack/client@17.2.0 emits the cap only when the caller supplied one:

if (normalizedOptions.top != null) queryParams.set("top", normalizedOptions.top.toString());

!= null, so top: 0 does reach the wire as top=0; an absent top is what returns the whole match set.

Call sites, not the adapter

Per the card's ruling, all fixes are at the call sites. convertQueryParams is unchanged — teaching it to honour unprefixed keys would widen the acceptance set the index signature exists to keep narrow.

app-shell ObjectView.tsx — the one that inverted

Its comment says "Fetch record count for footer display", the handler reads result.total first, and the value feeds the footer's recordCount. So the intent is unambiguous: give me the total, send no rows — and $top: 0 is the correct spelling of exactly that. limit: 0 was dropped, so the effect did the opposite of what it says: it downloaded every row of the object, on every mount and every refresh of every list view, to read one integer off the envelope.

The row-counting fallbacks (result.data.length, result.length) are removed rather than repointed, and that is the non-mechanical half of this fix. Once zero rows are requested, an empty data means "you asked for none", not "the object is empty" — keeping those arms would have turned a correct-but-expensive count into a confident 0 for any adapter that does not report a total. total is now the only reader; with no total the footer line is omitted, which the render already supports (typeof recordCount === 'number'). For the ObjectStack adapter this changes nothing observable: normalizeQueryResult always produces a numeric total (resultObj.total ?? resultObj.count ?? records.length), so the first branch already won.

app-shell AssignedUsersSection.tsx

{ $filter: { name }, limit: 1 }$top: 1. A single-record lookup by unique name, sitting one line from three correct $top calls.

plugin-dashboard DashboardFilterBar.tsx — a fourth site the card never named

Found by the new rule on its first repo-wide run, which is the argument for the rule in one site. It passed fields and top in one literal, so a filter's option list read every row and every column of its source object, under a comment describing it as a "client-side dedupe (top 200 records)". The same call then read records.items — not a QueryResult member — so against a real adapter the fallback resolved to [] and the filter offered no options at all.

$select reaching the wire for the first time is a behaviour change, so the projection is deduped: valueField === labelField is the common case and a repeated entry would be a new thing to send.

console sdui-workbench-preview.tsx

{ top: 200 }$top: 200, and all.recordsall.data. See the gate boundary below — this one is not reachable by the rule.

The rule: a sibling, not a second predicate

no-unprefixed-query-params is new rather than an extension of no-query-params-under-options, because the two need different anchors. The sibling's signature (a $-key under options) is unmistakable in any object literal, so it is context-free. Every name on this one's list — top, limit, filter, sort, select, count — is an ordinary object key elsewhere in this repo and carries no signal at all away from a finder call, so this one must be anchored to the call. They also must stay separately silenceable: one eslint-disable should not switch off both halves of the class.

Narrow on two independent axes: a closed list of known query-option spellings (not "any unprefixed key", which would report the adapter-specific params the index signature exists for), and only the second argument of a find/findOne call. Array.prototype.find is excluded by its own signature — a predicate first argument — rather than by naming.

fields → $select is one entry beyond the card's enumerated list. It is the same kind of entry as limit → $top and offset → $skip (an alias spelling, not a literal missing $), and it earned its place on the live DashboardFilterBar site rather than on speculation. Flagged here for review since the card's list was explicit.

The four properties, each as a run

(a) Fails on the live sites before the fix, passes after. Same three files, real repo config, before and after:

BEFORE  ✖ 186 problems (2 errors, 184 warnings)
  ObjectView.tsx           1687:47  error  `limit` is not a `QueryParams` key — write `$top`   object-ui/no-unprefixed-query-params
  AssignedUsersSection.tsx  109:90  error  `limit` is not a `QueryParams` key — write `$top`   object-ui/no-unprefixed-query-params
AFTER   ✖ 184 problems (0 errors, 184 warnings)

Two errors, not three — see the boundary below.

(b) False-positive control. The three correct neighbours in AssignedUsersSection.tsx ($top: 500 L124, $top: 200 L131, $top: 1000 L151/171) produced zero reports in that same run; only L109 fired. They are pinned as valid cases in the rule's test.

(c) The already-gated half still fails. no-query-params-under-options is unchanged, and the new test file re-runs it on both instances it was written for ({ options: { $top: 100 } }, and object-kanban's split spelling) as a cross-check that the two rules still divide the class and neither has swallowed the other.

(d) Wired into the config CI runs — verified by making it fire, not by reading config. The before/after runs above are npx eslint with the repo's own eslint.config.js; the rule is registered in eslint-rules/index.js and set to error in the same block as its sibling. scripts/__tests__/lint-workflow.test.ts passes (it imports the resolved flat config and asserts object-ui/* error rules exist); no rule name was added to any of the three prose surfaces that test scans.

Gate boundary: one site the rule cannot reach

sdui-workbench-preview.tsx holds its page source in a template literal — runtime page metadata, one TemplateLiteral token to the parser, never a CallExpression. No AST rule can see into it, so that site was fixed by hand and is not gated. Confirmed by measurement: with the rule enabled repo-wide over the pre-fix tree it reported the other sites and zero findings in that file while its { top: 200 } was live. Recorded with a suggested mechanism (extending the existing sdui-preview-page-source-* test family, which already glob-enumerates the harnesses and extracts their source strings) as #5944.

Population census

The rule is error-level and repo-wide, so it had to lint clean before landing. Enabled alone over the whole repo:

files linted: 3615   |   files with hits: 0     (exit 0)

The pre-fix run of the same scan is what surfaced DashboardFilterBar.tsx.

Verification

Repo-root vitest (package-scoped runs use a different config than CI), each named with its own result:

run result
vitest run eslint-rules/ 8 files, 174 tests passed
vitest run scripts/__tests__/lint-workflow.test.ts eslint-rules/ 9 files, 183 tests passed
vitest run …/AssignedUsersSection.test.tsx packages/plugin-dashboard/ 81 files, 755 tests passed
vitest run packages/app-shell/src/views/ObjectView apps/console/ 85 files, 965 tests passed
pnpm --filter @object-ui/{app-shell,plugin-dashboard,console} type-check all three Done, exit 0

The type-check used the script's real name type-check; the hyphenless spelling matches zero scripts and exits 0 without running anything. All three package names are echoed in the log, so the match was non-empty.

Node gates, each by name with its own verdict line:

check-changeset-presence  exit=0  ✅  5 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major  exit=0  ✅  No changeset declares a `major` bump.
check-control-bytes       exit=0  ✅  OK (scanned 4931 tracked text file(s); skipped 85 binary)
check-lint-coverage       exit=0  ✅  lint coverage: 46/46 packages linted, 0 with outstanding errors

Reverse verification

The records.items → data fix has a discriminating test, proven by mutation rather than asserted. Mutation confirmed on disk before the run (records?.items count 1, records?.data count 0, git diff --stat non-empty), restored by an EXIT trap and verified byte-identical afterwards. No rebuild needed and none claimed: the test imports ../DashboardFilterBar by relative path, so vitest transforms the source and no dist is involved.

Predicted direction — the new test alone goes red — and that is what happened: 1 failed, 13 passed, the failure being the QueryResult envelope case timing out on findByRole('option'), i.e. the dropdown opening empty. Every other case in that file mocks a bare array and takes the Array.isArray arm, which is why the defect had survived there.

Fixture triage

DashboardFilterBar.options.test.tsx pinned the dead spellingexpect.objectContaining({ top: 200 }) — so it passed while asserting the defect. It now pins the exact params object ({ $select: ['industry'], $top: 200 }); a partial matcher is what let the bare key sit there looking asserted.

Scope notes

Generated by Claude Code


Generated by Claude Code

…r `$`, and gate the shape (#5458)

`QueryParams` declares every query option `$`-prefixed and `convertQueryParams`
copies exactly those keys, so an unprefixed spelling reaches no branch and is
dropped — silently, and it type-checks because the type carries
`[key: string]: any` for adapter-specific params. A dropped cap makes the read
UNBOUNDED rather than truncated: the platform's GET list route has no default
page size.

Four live sites, all fixed:

- `ObjectView` fetched the footer record count with `{ limit: 0 }` — the one
  that INVERTED, since `$top: 0` means "no records": "count only" became "fetch
  every row", on every mount of every list view. Now `$top: 0`, reading `total`
  only; the row-counting fallbacks are dropped rather than repointed, because
  an empty `data` after asking for zero rows says nothing about the object.
- `AssignedUsersSection` — `{ …, limit: 1 }`, one line from three correct
  `$top` calls.
- `DashboardFilterBar` — `fields` AND `top` in one literal, so a filter's
  option list read every row and every column of its source; it also read
  `records.items`, not a `QueryResult` member, so a real adapter yielded no
  options at all. Found by the new rule, not by the card.
- `sdui-workbench-preview` — `{ top: 200 }` plus a `.records` misread, inside
  page-source metadata.

New `object-ui/no-unprefixed-query-params` rejects the shape at write time: a
known query-option name missing its `$` in the second argument of a
`find`/`findOne` call. Narrow on purpose — a closed spelling list anchored to
the call — because the index signature exists for adapter-specific params. The
sibling `no-query-params-under-options` is unchanged and still gates its half.

Part of #5458

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ3NihCHE9LUtHoGxo6A9f
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3231.1 KB 3990.2 KB
Main entry chunk (gzip) 153.6 KB 350 KB
Entry file index-Bf1TBbYg.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.13KB 3.77KB
app-shell (runtime-config.js) 13.57KB 4.78KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 504.18KB 114.10KB
core (index.js) 4.92KB 1.97KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 164.55KB 45.67KB
fields (index.js) 238.40KB 59.89KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.44KB
plugin-designer (index.js) 212.30KB 42.80KB
plugin-detail (index.js) 243.38KB 61.72KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 125.63KB 30.64KB
plugin-gantt (index.js) 164.15KB 39.88KB
plugin-grid (index.js) 200.79KB 54.26KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.86KB 27.22KB
plugin-map (index.js) 20.06KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.49KB 7.59KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.57KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 52.40KB 17.45KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.88KB 1.85KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Four live adapter.find calls pass an unprefixed query option — and no-query-params-under-options gates only the sibling half of the class

2 participants