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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path by claude[bot] · Pull Request #6828 · objectstack-ai/objectui · GitHub
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

fix(data-objectstack): lower rule-shaped filter arrays on aggregate()'s analytics path#6828
os-sam merged 2 commits into
mainfrom
claude/issue-6302-aggregate-filter-lowering

Conversation

@claude

@claudeclaudeBot 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

#6825aggregate()'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

…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-actionsgithub-actionsBot added documentation Improvements or additions to documentation data-adapter tests labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-CBhkXnjU.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)511.50KB116.32KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.17KB47.98KB
fields (index.js)240.93KB60.76KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.95KB8.33KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-sam
os-sam marked this pull request as ready for review August 30, 2026 06:17
@os-sam
os-sam added this pull request to the merge queueAug 30, 2026
Merged via the queue into main with commit 5127378Aug 30, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6302-aggregate-filter-lowering branch August 30, 2026 06:30
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapterdocumentationImprovements or additions to documentationtests

Projects

None yet

2 participants

@os-sam@claude