Skip to content

fix(core): the pivot preserves category-key ABSENCE, so the framework#4033 guard can fire (#4507) - #5281

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-4507-pivot-preserves-key-absence
Aug 19, 2026
Merged

os-support-ai merged 1 commit into
mainfrom
claude/issue-4507-pivot-preserves-key-absence

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #4507

Verified at 02779e625 (the commit this body's gate list was run against).

The measurement the route rests on (taken FIRST, before any edit)

The card's own precondition is binding: omitting [xKey] from a bucket only helps if key-absence is genuinely the shape a dataset query produces for a dimension it grouped by but never projected. So that was measured before anything was written.

Producer half — the key is OMITTED, never null-filled.

  • service-analytics' ObjectQL strategy builds each result row from scratch and writes a dimension key only when the engine actually returned that column — if (shortName in row) mapped[dim] = row[shortName]. A dimension that did not project simply never becomes a property.
  • The native-SQL strategy returns driver rows (ctx.executeRawSql) verbatim; a column the statement never selected is not a property of the row object either. Same shape, different path.
  • Pinned framework-side already: objectql-timedimension-projection.test.ts, case "keeps a non-granular timeDimension OUT of the projection", asserts expect(row).not.toHaveProperty('due_date'). And framework#4033 itself was measured on a live showcase app, where the rows read [{ count: 2 }, { count: 8 }] — the bucket key entirely gone while the numbers were right.
  • JSON cannot transport undefined at all, so no layer between the server and a chart can turn "no column" into a present-but-undefined key — even the executor paths that spell out[dim] = row[dim] emit a key JSON.stringify then drops.
  • ObjectStackAdapter.queryDataset passes rows through by reference (no per-row normalization).

Consumer half — executed end to end through the real adapter with the server answering the framework#4033 payload for dimensions: ['status','priority'], measures: ['est_hours'] (status never projected):

[1] adapter rows            : [{"priority":"High","est_hours":5},{"priority":"Low","est_hours":3}]
[1] "status" in rows[0]     : false
[1] Object.keys(rows[0])    : [ 'priority', 'est_hours' ]
[2] emitted data            : [{"High":5,"Low":3}]          <- JSON hides it
[2] Object.keys(data[0])    : [ 'status', 'High', 'Low' ]   <- the key IS there
[2] "status" in data[0]     : true
[2] data[0].status          : undefined
[3] guard fires on RAW rows : true
[3] guard fires on PIVOTED  : false

So: the row omits the key, the rows are still returned (they are grouped by the dimension that did project), and the pivot is exactly where the signal dies. An explicit null means the opposite — the column WAS projected and its value is null — which is the (None) bucket's case (objectui#4466 / #4497) and is untouched here. The two shapes are cleanly separated at the source, which is what makes route 1 coherent.

The measurement confirms route 1. No fork to report.

The route taken, and why

Route 1 — the pivot preserves key-absence, so the existing guard sees it. One guard, one meaning; nothing of the pivot's internals leaks into the renderer, and packages/plugin-charts is not touched at all (route 2 would have widened the file surface, and the dispatch ruled that a fork to report rather than a widening to take).

An emitted bucket now carries the axis key exactly when some row of it did:

const xId = chartBucketId(xRaw);
let bucket = byX.get(xId);
if (!bucket) byX.set(xId, (bucket = {}));
if (carriesKey(row, xKey) && !carriesKey(bucket, xKey)) {
  bucket[xKey] = isNullCategory(row, xKey) ? nullLabel : xRaw;
}

That is hasNoCategoryKey's own rows.some(key in row) lifted through the pivot's aggregation, so the guard reads the same fact before and after the pivot instead of the opposite one.

The second clause is not cosmetic. chartBucketId encodes an absent value and a stored null identically ([null], by ADR-0021's normalization), so one bucket can collect both key-absent rows and rows whose category is genuinely null. If any of its rows carried the key, the bucket is "known to be empty": it keeps its (None) label and draws. Refusing there would tell an author their query never projected a dimension that it demonstrably did — the mirror of the false sentence #4497 refused to print by not relabelling a key-absent row (None).

Why this survived four cards' worth of pins over the same rows

Both JSON.stringify and toEqual drop an undefined-valued key, so { status: undefined, Low: 3 } and { Low: 3 } read as identical everywhere — except in the one test that decides the placeholder, key in row. The new assertions say in / Object.keys / toStrictEqual out loud for that reason.

The pinned limit keeps its meaning

chart-series.nullCategory.test.ts, case "does NOT bucket a row that lacks the category key ENTIRELY", is not rewritten to match the change and does not fail: (None)-relabel stays refused for key-absent rows, which is what the case asserts. What changed is the stale limit recorded in its comment ("the pivot writes [xKey] onto every bucket … the guard cannot see it") — that limit is what this card exists to lift, so the note now says so, and the expectation is tightened from toEqual([{ status: undefined, Low: 3 }]) (blind in both directions) to toStrictEqual([{ Low: 3 }]) plus an explicit 'status' in assertion. Same meaning, now falsifiable.

Reverse verification — predicted before running, then run

Ablation: restore the pre-fix chart-series.ts from origin/main, keep the new tests. No rebuild is involved in the resolution path: the tests import ./chart-series.js, which vitest resolves to packages/core/src/utils/chart-series.ts (source), and vitest.config.mts additionally aliases @object-ui/core to packages/core/src. There is no dist and no package-exports hop anywhere in the path, so a stale build cannot hold a result — and the ablation going red is itself the proof that the source edit is what the run reads. The restore leg was confirmed by git status / git diff HEAD both empty (tree byte-identical to the commit) and the suites returning to green.

Predicted 5 red, named individually; observed 5 red, exactly those five:

test predicted observed
emits a bucket that does NOT carry the category key RED RED
reaches the framework#4033 guard, which the pivot used to defeat RED RED
does NOT relabel the key-absent bucket "(None)" GREEN (already true) GREEN
still names the second dimension's series GREEN GREEN
a genuinely NULL category beside a key-absent row keeps its "(None)" label RED RED
order does not decide it — the null row first GREEN (old code was right this way round) GREEN
a PARTIALLY projected first dimension draws what projected RED RED
ordinary pivot byte-identical, key ORDER included GREEN GREEN
null-valued category still becomes the bucket label GREEN GREEN
empty-string category is a VALUE and keeps it GREEN GREEN
never mutates the caller rows GREEN GREEN
nullCategory: does NOT bucket a row that lacks the category key ENTIRELY RED RED
Tests  5 failed | 73 passed (78)
AssertionError: expected false to be true   <- guard on the pivoted rows

After restore: Test Files 3 passed (3) · Tests 88 passed (88).

Gates run locally (at 02779e625)

  • pnpm --filter '@object-ui/core^...' build — dependency closure, green
  • pnpm exec vitest run packages/core/ packages/plugin-charts/ — 121 files, 2132 tests, green
  • pnpm exec vitest run packages/plugin-dashboard/ packages/plugin-report/ packages/data-objectstack/ — 117 files, 1280 tests, green (every buildChartSeries consumer)
  • pnpm --filter @object-ui/core type-check — green
  • pnpm exec eslint on the three changed source files — green
  • node scripts/check-control-bytes.mjs, check-changeset-presence.mjs, check-changeset-no-major.mjs, check-changeset-fixed.mjs, check-type-check-coverage.mjs — green
  • pnpm check:esm-specifiers, check:self-import, check:phantom-deps — green

The DOM half is already pinned and untouched: AdvancedChartImpl.missingCategoryKey.test.tsx renders the explanatory placeholder for key-absent rows. This PR is what finally lets a pivoted chart reach it.

Generated by Claude Code


Generated by Claude Code

…rd can fire (#4507)

`buildChartSeries`' pivot branch wrote `[xKey]` onto every bucket it created,
so `key in row` was unconditionally true downstream and `hasNoCategoryKey`
(plugin-charts) could never fire for a pivoted chart — the defence was dead
exactly where the defect it guards against lands.

An emitted bucket now carries the axis key exactly when some source row did,
which is the guard's own `rows.some(key in row)` lifted through the pivot's
aggregation. A bucket that DOES collect a key-carrying row keeps its `(None)`
label and draws, so objectui#4466/#4497's answer is untouched.

Measured first, per the card's binding precondition: an unprojected dimension
arrives with the key OMITTED (the strategies never null-fill it, JSON cannot
carry `undefined`, and the adapter passes rows through by reference), while an
explicit `null` means the column WAS projected — a different, drawn case.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 25.3 KB 350 KB
Entry file index-mipKd-hC.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.83KB 3.70KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.33KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.79KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.71KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.27KB 113.31KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.34KB
fields (index.js) 237.07KB 59.46KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.42KB 1.39KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 29.43KB 7.15KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 39.16KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 128.04KB 32.75KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.46KB 60.56KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 123.77KB 30.07KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 198.22KB 53.27KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.66KB 27.13KB
plugin-map (index.js) 19.96KB 6.56KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 42.84KB 11.77KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.34KB 20.61KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 31.56KB 10.70KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 19, 2026 00:51
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 31676be Aug 19, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-4507-pivot-preserves-key-absence branch August 19, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

buildChartSeries' pivot branch writes the category key onto every bucket, so framework#4033's hasNoCategoryKey guard can never fire for a pivoted chart

2 participants