Skip to content

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path - #6828

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering
Aug 30, 2026
Merged

os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claude claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #6302

Step 1 of the maintainer's Key-2 sequencing ruling (objectstack#12039): sequence the consumer first. The spec-side convergence of ComponentPropsMap['element:number'].filter to z.array(ViewFilterRuleSchema) is step 2, on a follow-up in objectstack carrying Blocked-by: the objectui pin bump — it is NOT in this PR.

The defect, confirmed on this base rather than taken from the card

Both halves of the card's premise verified on 26896c689:

  • find() does lower. convertQueryParams (packages/data-objectstack/src/index.ts:3448) calls translateFilterArray(params.$filter) at line 3479. The $expand / $search route reaches the same machinery through translateFilterToAST.
  • aggregate() did not. The analytics branch assigned payload.where = params.filter verbatim (line 4601 pre-fix) and posted it to client.analytics.query.
  • translateFilterArray is reusable as-is — a module-level function in the same file, three call sites away. Nothing had to move or be exported, so this is the reuse the ruling asked for and not a second lowering.

The refusal was then measured against the real runtime door rather than inferred. lowerAnalyticsWhere (objectstack packages/services/service-analytics/src/strategies/filter-normalizer.ts:1508, shared by both aggregation strategies) does:

if (where.length === 0) return null;                              // [] is "no filter"
if (!isFilterAST(where)) throw filterArrayNotLowerableError(where); // <- the refusal

and the spec's own gate answers, measured:

isFilterAST([{ field: 'stage', operator: 'equals', value: 'won' }])  // false
isFilterAST(['stage', '=', 'won'])                                   // true

So an authored ViewFilterRule[] that a LIST renders correctly threw on every analytics-capable deployment — the default one, since the CLI always loads analytics — and rendered element:number into its error state.

The change

packages/data-objectstack/src/index.ts — one assignment:

payload.where = Array.isArray(params.filter)
  ? translateFilterArray(params.filter)
  : params.filter;

Non-array filters stay untouched on purpose: the MongoDB-style object this branch was written for is what /analytics/query already accepts, and translating it would be a semantic change the ruling excludes ("No ruled semantic changes — only ordering").

One shape I checked and deliberately did NOT change: an empty array. [] is truthy, so where: [] still goes out — and the door above answers [] with return null before the refusal, so it is accepted. Byte-unchanged, and now pinned as such.

Tests

New: packages/data-objectstack/src/aggregate-filter-lowering.test.ts — 22 pins.

  • Rule arrays lower on the aggregate path: single rule, operator aliases, several rules joined with and, and rules SPREAD into a logical node (['and', ...rules, ...tuples], the commonest composite) lowered at depth.
  • The card's acceptance criterion stated literally: the rule array and the AST-tuple equivalent produce the SAME analytics where.
  • A negative control on every lowering pin: isFilterAST(rawRules) is false and isFilterAST(loweredWhere) is true, so a lowering that produced some other non-AST shape could not pass.
  • Byte-unchanged: AST tuple, logical node, legacy nested arrays, record-shaped filter, record-shaped filter with an operator object, empty array, and the no-filter case (no where key at all).
  • The find() path is unchanged, two ways: its own standalone expectations, plus a six-row cross-path parity table asserting aggregate()'s where and find()'s filter= are the same value on the wire for the same input. That is what stops the two paths drifting the way the two find() routes once did.
  • The one place the paths legitimately differ is recorded, not reconciled: convertQueryParams drops an empty filter, the analytics payload keeps [].
  • A rule the adapter cannot translate now refuses on the aggregate path too (MalformedFilterError), with nothing posted to analytics and no plausible-looking number from the fallback instead.

No existing test moved. That is worth stating plainly, as the card asks: the analytics path had NO coverage of the request body's where. The only filter-related aggregate test (aggregate-capability.test.ts:141) exercises the FALLBACK and asserts on the /data GET URL, so it never looked at what client.analytics.query was sent.

Ablation — the pins fail without the fix

Fix committed first, then the pre-fix assignment restored on disk (anchor asserted, injected/removed strings counted, blob hash changed f4fb56f8 to 4dd4ea56). The test imports the adapter by relative SOURCE path (./index), so vitest compiles the mutated file directly — no dist round-trip to invalidate.

Tests  11 failed | 11 passed (22)

The direction is the point: the 11 lowering / parity / refusal pins go red, and the 11 byte-unchanged pins stay green — so they are not merely following the change. A representative failure:

- ["and", ["stage","=","won"], ["amount",">",100]]   (expected)
+ ["and", {"field":"stage","operator":"eq","value":"won"}, ["amount",">",100]]   (received)

Restore proven by observation, not by exit code: git diff HEAD empty, git status clean, and the on-disk blob back to f4fb56f86af9ea234fb938db30596d9cf6e122bd, identical to the HEAD blob.

Verification, all at 0d848bae5 (final commit)

vitest packages/data-objectstack/          48 files, 662 tests passed
type-check (@object-ui/data-objectstack)   clean
check:control-bytes                        OK (5649 tracked text files)
check:spec-symbols                         OK
check:phantom-deps                         OK
check:self-import                          OK
check:readme-exports                       OK (383 self-imports judged, 0 fabricated)
check:doc-fences                           OK (223 documents)
check:doc-snippets                         267 of 267 blocks judged, 0 failed
check:vi-mock-specifiers                   OK
changeset presence / no-major / overwrite  OK

The typecheck really does cover the new test file — the package's tsconfig has no test exclude, and tsc --listFiles finds both changed files (1 hit each), so "typecheck clean" is a statement about them and not a vacuous one.

One declared narrowing. pnpm lint is turbo run lint across 47 packages; I ran the changed package's own eslint . instead — 0 errors, 404 warnings, all pre-existing no-explicit-any at warn. Three pieces of evidence that this narrowing excluded nothing: (1) the population comes from eslint's own config resolution, not my guess; (2) --format json reports 54 files linted, and the new test file's 11 warnings are the same rule and the same count as the sibling aggregate-capability.test.ts; (3) type-aware linting is NOT enabled — eslint.config.js uses tseslint.configs.recommended with no project or projectService — so this diff cannot move the verdict on any file it does not contain. The other 46 packages read no file of this diff.

Scope

Everything is inside packages/data-objectstack/ (adapter, its new test, its README) plus one .changeset/ entry — clear of the thirteen unmerged PRs on this seat, none of which touches this package.

The README addition is AGENTS.md #2: the "Filter Conversion" section documented the MongoDB-style object find() accepts and never mentioned ViewFilterRule[], the shape this whole card is about.

Filed, not fixed

#6825 — aggregate()'s OTHER branch (looksLikeSpecShape, which posts to client.data.query) still assigns where unlowered, and ObjectChart.tsx hands the same resolved filter to both branches (lines 483 and 491). After this PR, one chart's filter is lowered and another's is not, decided by which aggregation shape it uses. Left alone deliberately: that where is the spec Query DSL's, declared a FilterNode in query.zod.ts, so lowering it there would be deciding what a filter MEANS — the thing this card's order says to stop and report on rather than do.

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 29, 2026 20:34
…te path

`find()` has translated `[{ field, operator, value }, ...]` into the server's
filter AST since `convertQueryParams` was written. `aggregate()` did not: the
analytics path assigned `payload.where = params.filter` verbatim and posted it
to `/analytics/query`.

The two doors are not equally forgiving. `lowerAnalyticsWhere` in
`@objectstack/service-analytics` — shared by BOTH aggregation strategies, so no
deployment gets a lenient reading — accepts AST tuples and throws on an array of
rule objects ("received a 'where' array that is not a filter"). The spec's own
`isFilterAST` gate agrees about the same value. So a stored `ViewFilterRule[]`
that a LIST renders correctly rendered `element:number` into its error state on
every analytics-capable deployment, which is the default one: the CLI always
loads analytics.

Reuse, not a second lowering: an array filter goes through the same
`translateFilterArray` the `find()` path runs, so the two paths cannot drift the
way the two `find()` routes once did. The new tests assert that as cross-path
parity on the wire, not just as a shape.

Non-array filters stay untouched — the MongoDB-style object this branch was
written for is what `/analytics/query` already accepts, and translating it would
be a semantic change this card does not make. Already-AST arrays, record-shaped
filters, the empty array and the no-filter case are byte-unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
… read paths share

The README's "Filter Conversion" section documented the MongoDB-style object
`find()` accepts and never mentioned the array shape server-driven view configs
actually store (`ViewFilterRule[]`) — the shape objectui#6302 is about. Record
it once, on both read paths, together with the properties the tests pin: alias
mapping, lowering at depth inside a logical node, `MalformedFilterError` rather
than a dropped condition, and non-array filters passing through untouched on
the aggregate path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions github-actions Bot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 45 chunks) 3174.0 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-CBhkXnjU.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) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
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) 511.50KB 116.32KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.17KB 47.98KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
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.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.03KB 32.64KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.95KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
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) 4.47KB 1.63KB
react (SchemaRenderer.js) 76.75KB 25.49KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
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) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
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) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 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 (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
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

Merged via the queue into main with commit 5127378 Aug 30, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapter documentation Improvements or additions to documentation tests

Projects

None yet

1 participant