Skip to content

feat(core,data-objectstack,plugin-grid): consume the platform's per-column sortability signal - #6109

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-5729-column-sortability-signal
Aug 24, 2026
Merged

feat(core,data-objectstack,plugin-grid): consume the platform's per-column sortability signal#6109
yinlianghui merged 2 commits into
mainfrom
claude/issue-5729-column-sortability-signal

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5729

Downstream leg of objectstack-ai/objectstack#10235 (maintainer 2026-08-23, ruling A): the platform serves an explicit per-column sortability signal, and the grid reads it rather than re-deriving "virtual means unsortable" from field type.

All gate results below are from 57870ce55, the branch head.

The measured envelope shape, versus the one the thread described

Read from the merged upstream change in the objectstack checkout, not from the issue thread. The description was accurate in every particular.

  • packages/spec/src/api/protocol.zod.ts:413GetMetaItemResponseSchema gains sortability: ObjectSortabilitySchema.optional(), on the envelope beside item, never inside it (FieldSchema is a strictObject, so the key has to stay un-authorable).
  • packages/spec/src/api/sortability.zod.ts{ fields: Record<string, { sortable: boolean; reason?: 'virtual-type'; caveat?: 'unprovisioned-anchor' }> }, exactly as claimed.
  • packages/rest/src/rest-server.ts:2609 — computed in translateMetaEnvelope, the one seam every single-item exit passes through, so the signal is present on every branch including the cached one.

Two details worth carrying forward that the thread did not spell out: the projection's domain always includes id even when the document declares no such field, and it is computed from the FINAL post-masking document, so its domain equals the field set this caller is served.

Verified by running the platform's own resolveObjectSortability from the installed @objectstack/spec@17.2.0 against the oracle documents. crm_opportunity.expected_revenue and showcase_project.budget_remaining both come back { sortable: false, reason: 'virtual-type' }; amount, name, budget and id come back { sortable: true }.

The real work: the signal was reaching the browser and being thrown away

ObjectStackAdapter.fetchObjectSchemaFresh unwrapped the response to data.item and returned it. The envelope key — and with it the entire signal — was discarded one line before its only consumer, so every UI reader saw a document with nothing on it and could only conclude the platform had sent nothing. That line is the substance of this change.

It now survives the unwrap, carried on the schema under Symbol.for('objectui.objectSortability'). The symbol is load-bearing, not decoration: JSON.stringify, Object.keys and spread all skip it, so a schema handed back at a metadata write endpoint cannot take the projection into a body the server parses strictly. Putting it on a string key would have undone the upstream decision to keep it off item, one repo away. Pinned directly, with a counter-probe proving the assertions measure invisibility rather than a failed attach.

The contract, and the asymmetry it turns on

@object-ui/core gains the one spelling: isPlatformSortableField(projection, name) is true iff an entry EXISTS for the name and says sortable: true.

Absence is a refusal. It is how the platform encodes an unknown name, a dotted path and an unprovisioned audit column, all three of which the runtime doors reject — so the !== false spelling every other optional flag in this repo uses gets exactly that family backwards. Ablation A below is the pin.

A projection that is absent ALTOGETHER is a different question with a different answer. readObjectSortability returns undefined when the metadata response carried no sortability key, which means "no signal was served", not "nothing is sortable"; the two are typed apart so a caller cannot conflate them. isPlatformSortableField takes a non-optional projection for the same reason.

The spec's own names are re-exported rather than restated. check:spec-symbols caught four hand-written declarations under names @objectstack/spec/api already owns (FIELD_UNSORTABLE_VIRTUAL_TYPE, FIELD_SORTABLE_UNPROVISIONED_ANCHOR, FieldSortability, ObjectSortability); the second commit replaces all four with re-exports, which is the contract-first shape and one fewer thing to drift.

What changed in the grid

The header click on a refused column ceases to exist, so neither the old silent-unordered result nor the 400 INVALID_SORT that replaced it is reachable from it.

The restore leg gets its own guard.DataTable emits onSortChange only out of handleSort, itself gated on col.sortable !== false, so withholding the affordance already stops a NEW refused sort from being created. It says nothing about one ALREADY in stored view state, persisted before the signal existed: replayed, it would paint an active-sort arrow on a header that offers no click and ride into the next persistViewPatch({ sort }) the toolbar issues for an unrelated reason. Both directions now run through the same filter — what the grid renders the sort state as, and what it emits back to whoever persists it.

The relational carve-out is untouched, and deliberately not delegated to this signal. Measured and pinned: the projection answers sortable: true for a lookup, because the platform's question is whether it can order by the stored foreign key, which it can. The grid's question is whether that order means anything beside a column of names, and it does not. Folding the two together would have handed every relational header its sort click back.

Columns carrying caveat: 'unprovisioned-anchor' keep their click — default A, held rather than decided. See the ruling request below.

Verification

Directions predicted before running; both ablations restore under trap ... EXIT INT TERM with cwd-independent commands, and git diff HEAD --stat was confirmed empty after each. No rebuild is needed for either: the root vitest.config.mts aliases every @object-ui/* specifier to that package's src/, so the mutated source is what the run executes.

Ablation AisPlatformSortableField rewritten to the naive !== false spelling. Predicted: red on the ABSENT-entry cells ONLY, with the sortable: false cells staying green, since that is the whole asymmetry. Observed exactly that: 4 failed, 15 passed; the four are "refuses an ABSENT entry", "refuses an empty / malformed field name and a malformed entry", "drops a persisted sort on a column the platform refuses", and the grid's "withholds a column ABSENT from the projection". expected_revenue and budget_remaining stayed green throughout, confirming the two rules are separately load-bearing. Mutation confirmed on disk by grepping the injected marker (1) and the removed anchor (0) separately, with the anchor asserted unique first.

Ablation B — the transit carry dropped (return item). Predicted: the three carry cells red, the two "no projection served" cells green. Observed exactly that: 3 failed, 2 passed.

Cross-package type reverse-check — a value the new signature must reject, fed to isPlatformSortableField from plugin-grid. Red as predicted, with error TS2345 naming the spec's literal union ("virtual-type" | undefined, "unprovisioned-anchor" | undefined), which proves tsc read the freshly built .d.ts across the package boundary rather than a cached one.

One red found and fixed rather than reported: my first fixture returned the same object literal from every mocked response, and the adapter stamps the projection onto the document it is handed — so a previous test's stamp leaked into the "no signal served" cells and made them pass while measuring nothing. The mock now returns fresh JSON per response, as a real res.json() does.

Gates (each exit code captured before any pipe; each verdict quoted from the gate's own output):

gateexitresult
pnpm --filter @object-ui/core type-check0script name echoed, so not a zero-match no-op
pnpm --filter @object-ui/data-objectstack type-check0same
pnpm --filter @object-ui/plugin-grid type-check0same
npx vitest run packages/core/src packages/data-objectstack/src0Test Files 143 passed (143) / Tests 2628 passed (2628)
npx vitest run packages/plugin-grid/src0Test Files 86 passed (86) / Tests 811 passed (811)
pnpm --filter '...@object-ui/core' type-check (downstream)2see below
npx eslint <merge-base delta>0377 problems (0 errors, 377 warnings) — all pre-existing no-explicit-any
pnpm check:spec-symbols0after the re-export commit; RED before it, and correctly so
pnpm check:esm-specifiers0no un-ledgered package emits an extensionless relative specifier
pnpm check:phantom-deps0Every in-scope import is declared by the package that publishes it
pnpm check:self-import0No package names itself inside its own src/
node scripts/check-changeset-presence.mjs07 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-fixed.mjs0All workspace packages are in the changeset fixed group
node scripts/check-changeset-no-major.mjs0No changeset declares a major bump
node scripts/check-control-bytes.mjs0scanned 5040 tracked text file(s)

The downstream sweep's direction, stated because the claim is unreadable without it: '...@object-ui/core' is the PREFIX form, so it selects the packages that DEPEND ON core — the consumers a contract change lands on. 35 ran the script; exactly one failed, apps/site, on Cannot find module '@object-ui/example-schema-catalog'. That is the unbuilt-closure class the sweep is known to produce for workspaces outside packages/** (I built --filter './packages/**' first, which is why packages/components and plugin-dashboard pass here and did not on the first attempt). No error TS anywhere else, and nothing naming sortability.

Gate set derived by enumerating each CI job's own step list rather than top-level script names, per the standing rule — which is how check:esm-specifiers and check:spec-symbols got run at all; both live as steps inside the Type Check job under names that do not read like one, and check:spec-symbols was a genuine red.

Ruling requested, not taken

caveat: 'unprovisioned-anchor' stays on default A — the click is kept. The runtime accepts these sorts; refusing what the platform does not refuse would recreate declared-versus-enforced drift in mirror image. Option B (drop the affordance) or C (click plus a degraded-order warning) remain open and are the maintainer's to pick; the caveat is preserved through the whole path and pinned, so either is a small change on top.

The one thing I would like ruled, because it is the closest this change comes to scope item 3's prohibition: when NO projection was served, withSortability still falls back to the pre-existing isUnmaterializedFieldType read. My reasoning for keeping it, and why I think it is not the shadow derivation the ruling forbids: it is bound to @objectstack/spec's own SEARCH_VIRTUAL_TYPES, the identical predicate the platform computes its projection from, so it cannot disagree about formula; it is unreachable the moment a backend serves the signal; and deleting it outright would hand every formula header its refused click back on any deployment older than objectstack#10235. It is a compatibility floor, not a second judge, and it is commented as one — but if the ruling wants it gone now, it is a two-line deletion plus one test.

Runtime dependency and what was actually runnable

The installed @objectstack/spec@17.2.0 carries the change, and the pins here run against that resolver directly. I did not boot a dev stack, so the three oracle cells are pinned at unit level against the platform's own resolver rather than claimed as a served-backend measurement — which is the discipline the requeue obligation asked for when the runtime leg cannot be run, and I would rather report it that way than report an oracle I did not execute. Every negative cell carries its positive control in the same render (a sortable sibling column, still clickable, still putting its $orderby on the wire), so "the click is gone" cannot be satisfied by a grid that rendered nothing.

Scope

packages/components was not needed — DataTable already renders no cursor, no click handler and no sort icon for col.sortable === false, and emits onSortChange only from the gated handleSort. plugin-detail untouched.

Filed out of scope, unassigned: #6108 — the other two sort-axis consumers (ListView's toolbar sort picker and RelatedList) still re-derive from field type. The picker one is not cosmetic: its in-use exception keeps a refused field listed, so an unrelated edit in that popover re-emits the whole sort array into persistViewPatch({ sort }). This PR closes that leak at the grid seam; the picker is a second door onto the same stored state.


Generated by Claude Code

os-litantand others added 2 commits August 24, 2026 16:55
…n sortability signal
Downstream leg of objectstack#10235's ruling A.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
…tating them
check:spec-symbols caught four hand-written declarations under names
@objectstack/spec/api already owns. Re-exported, and the changeset added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3220.9 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-BLZl_NO2.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)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
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)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)168.39KB46.47KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.08KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
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)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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.49KB2.14KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 24, 2026 17:53
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit 490d9a9Aug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5729-column-sortability-signal branch August 24, 2026 18:04
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@os-litant