…e column the aggregate projects (objectui#8266)
A dashboard chart widget with `aggregate: { function: 'count', groupBy: 'status' }`
and no `field` rendered a plot frame with the category ticks drawn and zero marks
in it — no error, no empty state.
Both relays built the series binding as `aggregate?.field || (options.yField ||
'value')`, resolving to `'value'`, while an object-bound fieldless count projects
its value under the literal `'count'` (framework#3701). Neither renderer guard
fires on that: the rows do carry the category key, and the series array is not
empty.
`chartMeasureKey` (new, `@object-ui/core`) delegates to the contract's own
`chartAggregateValueKey` from `@objectstack/spec/ui`. Both relays and the row
projection in `plugin-charts` now read it, so the series binding and the column
it binds to cannot disagree again.
The authored-rows branch in both relays keeps reading the raw `yField`: it is
reached only when the widget's rows are a literal array, where there is no
aggregate to consult and the author's key names a column of their own rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Fixes #8266
It reproduces — measured before anything was written
The card was source-derived and said so; its own instruction was to reproduce first and close it if the chain did not hold. It holds, and here is the measurement rather than the derivation.
Seam (both relays, through the real render path, recorder registered for
object-chart): a widget withprovider: 'object'andaggregate: { function: 'count', groupBy: 'status' }composedseries: [{ dataKey: 'value' }]onDashboardGridLayoutAND onDashboardRenderer, over an aggregate the row projection keys'count'.Render (
ChartRendererat a fixed 480x320, overaggregateRecords(..., {function:'count', groupBy:'status'})output[{status:'open',count:2},{status:'paid',count:5}]):series[0].dataKeyvalue— what the relays composed.recharts-bar, 0.recharts-rectanglecount— the column the rows carry.recharts-bar, 2.recharts-rectangleSame rows, same harness, one key apart. The x axis drew
open/paidin both. So the failure is real AND silent: a plot frame with the categories on it and nothing in it, which is what an author with a genuinely empty object also sees. Neither guard this renderer carries declines to fire by accident —hasNoCategoryKeyis satisfied because the rows DO carrystatus, andhasNoPlottableSerieskeys onseries: [], which this is not.One correction to the card
The card says "No error, no empty state". That is true only when
options.xFieldis also authored. Measured: with noxFieldthe relays floorxAxisKeyto the literal'name', the rows carrystatus, and the chart renders the loudmissing-category-keyrefusal — namingname, a key the author never wrote. That is the CATEGORY half of the same relay gap. This PR does not address it; it is filed separately, with its own measurement, as objectui#8269.The loose end at
DashboardGridLayout.tsx:261— settledIt is a legitimately different branch, not a third spelling.
:261sits after theisObjectProvider(widgetData)early return, in the branch whose rows are an authored literal array (DashboardRendererlabels its twin// Static inline data array.). There is no aggregate there to consult, and the author'syFieldnames a column in their own rows, soyFieldis the correct binding and must stay. Pinned in both directions by the seconddescribeblock of the new seam test — including that the branch composestype: 'chart'withaggregate: undefined— so a later "unify the two" refactor cannot quietly break it.Corollary: the answer is one resolver for the question the aggregate path asks, and
:261legitimately does not ask it.The fix — one authority, and it is the contract's
chartAggregateValueKeyin@objectstack/spec/uialready IS the single answer to "which column does an object-bound aggregate project its measure under" — its own doc calls it "what a chart's series / y-axis binding must name". The repo had three independent opinions of that question and two of them disagreed for a fieldless count.@object-ui/coreexportchartMeasureKey(aggregate, fallback)— a seam overchartAggregateValueKey, not a restatement of the rule.coreis the home for exactly this (thehumanizeLabelmove in objectui#5444,buildChartSeriesunder ADR-0021) and already imports@objectstack/spec/uiat runtime, so no package gains a new dependency edge.aggregateValueKeyinplugin-charts— the ROW projection — is routed through the same function, so the two halves cannot drift again. Behaviour-preserving: the delegation's tail (?? aggregate.function ?? 'count') reproduces the old three-rung read for the only shapes the contract answersundefinedfor, which are shapesChartAggregateSchemarejects outright. Both are now pinned in the existingObjectChart.aggregateResultColumns.test.ts.The inverse fix was not available and was not attempted: making the row projection key a fieldless count
'value'contradicts a pin already onmainand renames the column every other consumer reads, including the engine'sCOUNT(*)alias.What moves on screen — stated, not assumed
A chart that was blank now draws. No chart that already drew changes: a field-bearing aggregate resolves to its raw field under both the old and the new reading, and a chart with no
aggregatekeeps the author'syField(pinned).One authored key changes meaning: a
yFieldwritten on an object-bound chart that ALSO declares an aggregate no longer wins over the aggregate's own column. It named a column of the RECORDS, and a grouped aggregate does not return records — so it plotted nothing before, for exactly the reason'value'did.Ablation — the pins can fail
Committed first, then two disjoint mutations in one run, then restored by state (
git hash-objectequal togit rev-parse HEAD:PATHfor both files, andgit diff HEADempty), never by an exit code. Each mutation was proved on disk by agrep -cFcount of the removed text going to 0 and the injected text to 1 before anything ran.Mutation A —
chartMeasureKeyreverted to the pre-fix readingaggregate?.field ?? fallback:Both relays go red, which is what proves both of them route through the one authority rather than one of them happening to agree.
Mutation B —
aggregateRecords' value key reverted to the rawfield, the state before framework#3701:Totals: 9 failed, 22 passed of 31. The 22 are the arms that must NOT move — the field-bearing aggregate, the no-aggregate floor, the authored-rows branch — so the ablation discriminates rather than reporting one undifferentiated red.
draws NOTHING, silently, when the series names "value"stayed green under both, correctly: it is a pin on the renderer, not on either mutated path.Verification
Run from the repo root (the package-dir invocation is refused here by design, objectui#3378). Exit codes captured before any pipe.
pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/— 267 files, 3902 tests passed.turbo run buildfor both packages' dependency closures, thenturbo run type-checkfor the three packages — 12 and 16 tasks, all successful.node scripts/check-changeset-presence.mjs— its own verdict line: 5 source files of 3 released packages changed, 1 changeset declared.check-changeset-no-majorgreen (this repo ships breaking asminor; this is apatch).check:control-bytes,check:vi-mock-specifiers,check:vi-mock-inherit,check:spec-symbols,check:phantom-deps,check:self-import,check:unreferenced-sources,check:side-effects-array,check:element-data-source-declaration,check:entry-guard— all exit 0. After a fullturbo run build(44 tasks):check:sdui-registration-pins,check:readme-exports,check:dist-completeness— all exit 0.eslint --no-inline-configover the 9 changed TS/TSX files: 0 errors, 89 warnings, of which 80 are pre-existing in the three large files this PR touches (64 / 8 / 8) and 9 areno-explicit-anyin the two new test files, the same shape every schema-driven test in this repo carries. ESLint here is not type-aware (noproject/projectServiceineslint.config.js), so this diff cannot move the verdict on a file it did not touch. The whole-repoturbo run lintis CI's.turbo ls --affectednames. They are affected only through the dependency graph — this diff adds an export and changes no existing signature — and CI runs the sharded farm.Bundle Analysiswill be RED, and the reason is mostly not this PRMeasured in this worktree, same harness, two builds:
origin/mainsources (0fa7a9c83)MAX_EAGER_CLOSURE_GZIP_BYTESThis PR adds 14 gzipped bytes.
mainwas sitting 8 bytes under the ceiling before it.That ceiling was set in objectui#6776 over a baseline of 3,222,314 on
3d257c85a, deliberately carrying 45,686 bytes of headroom — 0.50x the regression the gate is calibrated to catch. 45,678 of those bytes have since been consumed by drift, so the aggregate gauge is parked at 1.00x its own sensitivity: it can no longer catch the regression it exists for, and the nextpackages/**PR from anyone tips it regardless of what that PR does.I have not raised the constant. The gate's own header calls re-baselining a decision that must be visible and justified by what the bytes buy — and the decision here is about 45.7 KB of accumulated drift that this card did not cause, not about my 14. Squeezing the fix under an exhausted ceiling would be the same evasion in the other direction. Handing it up instead.
Bundle Analysisis not in this repo's merge-queue required set (Lint,Type Check,Testshards 1-4,Build & E2E,Build Docs,Changeset Declaration), so it gates nothing mechanically — but it is a true red and wants an answer.Overlap note
packages/core/src/index.tsis a shared barrel; this PR adds oneexport * fromline to it and touches nothing else there, so a concurrent barrel edit merges as a union.Not done here, on purpose
groupBy-only object chart is still refused for lackingname. Filed with its measurement; that card remains open and this PR does not address it.'name'/'value'literal floors at the relay faces, which objectui#7547 tracks.Drafted by the dev seat in session
session_01YBWFb5YgMU5dw8p2VKj16S. Draft on purpose — the PM seat flips and lands.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code