fix(types): make the 8-literal union the one canonical TableColumn.type - #6370
Merged
os-support-ai merged 1 commit intoAug 25, 2026
Merged
Conversation
`TableColumn.type` disagreed three ways: the interface declared 8 literals, the zod mirror declared `z.string()` and accepted anything, and the renderer branched on a third set it could only read through an `as any` cast. Per the maintainer ruling of 2026-08-25 (Option B), the 8-literal interface union is canonical: - `TABLE_COLUMN_TYPES` is now the single declaration of the vocabulary, and the zod mirror builds its `z.enum` from it, so the two cannot drift. - Column-inference producers fold their inferred value onto that vocabulary at their emit seam (`normalizeTableColumnType`) instead of forwarding an object schema's field type verbatim. Two producers do this: `ObjectGrid` and `ObjectDataTable`. An out-of-union type drops the `type` annotation — never the column. - The renderer's undeclared dialect (`int`/`integer`/`float`/`double` in `NUMERIC_EDIT_TYPES`, the `datetime-local` branch) is gone and the `as any` cast with it, so the read is typed against what the interface publishes. A value-level parity pin covers all three ends; objectui#5684's guard is key-set only and cannot see value drift.
Contributor
✅ Console Performance Budget
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
Size Limits
|
os-support-ai
marked this pull request as ready for review
August 25, 2026 18:20
os-support-ai
deleted the
claude/issue-5853-tablecolumn-type-canonical-union
branch
August 25, 2026 18:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5853
TableColumn.typehad three disagreeing authorities. The interface declared 8 literals, thezod mirror declared
z.string()and accepted anything, and the renderer branched on a thirdset it could only reach through an
as anycast.Implements the operative maintainer ruling — 2026-08-25,
os-steve, verbatim 「其他同意」,Option B: the 8-literal interface union is the canonical value set. The earlier
2026-08-24 ruling (canonical = the renderer's live read set, 12 values) is superseded and is
not what this PR builds.
1. Corpus measurement — ran FIRST, before any edit (the fork clause)
The ruling's step 1: measure authored out-of-union spellings before tightening, and stop
if authored usage is material. It is not material. It is zero.
Method, with a control probe so a zero-hit reads as real. A structured walk of every
JSON file under
examples/,content/,apps/,e2e/,docs/andpackages/(591 files parsed), collecting every object carrying
accessorKey(theTableColumndiscriminator) and every object inside any
columnsarray, plus a regex sweep of thenon-JSON corpus. Control:
type: 'currency'→ 22 hits;type: 'banana'→ 0 hits.type: 'int'in a column position — repo-widetype: 'integer'in a column position — repo-widetype: 'float'in a column position — repo-widetype: 'double'in a column position — repo-wideaccessorKeycarrying atypenumber,text,currency— all declared)accessorKeycarrying an OUT-OF-UNIONtypeThe only out-of-union hits in the whole corpus, with the file list the ruling asked for:
examples/schema-catalog/src/schemas/plugin-grid/object-grid-columns.json—$.columns[2],$.columns[4]→type: "select"examples/schema-catalog/src/schemas/plugin-grid/object-grid-selection-summaries.json—$.columns[3],$.columns[4]→type: "select"content/docs/plugins/plugin-grid.mdx—type: "select"/type: "badge"inobject-gridexamples⭐ None of these is a
TableColumn. Their keys arefield+type, which isListColumn(@objectstack/spec/ui) — ObjectGrid's column INPUT vocabulary, a differentdeclared type that this PR does not touch. They are authored against the spec's field-type
vocabulary and keep validating exactly as before. They matter here only as evidence for §4:
they are what flows INTO the inference whose output §3 folds.
The three repo-wide
type: 'integer'hits are JSON-Schema primitives inpackages/app-shell/.../json-schema-to-fields, unrelated to this key.Verdict: fork clause not triggered — no authored document breaks.
2. Coordinates re-derived on this branch's base (
e4559d1e0)Both rulings' line numbers were stale, and so were the PM's.
e4559d1e0as anycast:2151(ruling) /:2152(card) /:2192(dispatch)packages/components/src/renderers/complex/data-table.tsx:2192✅ dispatch correct:2206(ruling) /:2236(dispatch)packages/plugin-grid/src/ObjectGrid.tsx:2236✅ dispatch correctdata-display.zod.ts:109:109✅type: z.string().optional().describe('Column type')data-display.ts:255:255✅ unchanged from the cardOne correction to the card's own model of the renderer. The card and both rulings treat
NUMERIC_EDIT_TYPESplus adatetime-localbranch as the renderer's read set. Measured, therenderer reads
TableColumn.typeat exactly one site (:2192→editType), feedingthree branches.
formatCellValueis type-agnostic (regex on the value). So the live read setis
{date, datetime, datetime-local, number, currency, percent, int, integer, float, double}— everything else already fell through to the text input before this PR.
3. What happens to an out-of-union field type at the seam (T1)
The seam forwards an object schema's field type, whose vocabulary is
@objectstack/spec'sFieldType. Measured against the installed dist: 49 options, of which 42 are outside theunion; 7 of the 8 declared literals are spec field types and only
actionis UI-only.int/integer/float/double/datetime-localare not in the spec enum at all— the renderer's extra dialect was never a spec vocabulary.
Ruled disposition, and it is total — three outcomes, no fourth:
int/integer/float/double→number);undefined— thetypeANNOTATION is dropped, and the COLUMN IS NEVER DROPPED.This is where the 42 spec types (
select,lookup,user,file,formula, …) land, andit is behaviour-preserving at the only consumer that reads the key:
data-table's editorbranches on
date/datetime/ the numeric set and otherwise falls through to a text input— which is exactly the
undefinedpath. The dedicated widget those fields DO get comes fromthe host's
renderCellEditor, which resolves the field throughcolumn.accessorKeyandnever reads
type(ObjectGrid.tsx:3036). Pinned behaviourally, both halves: the columnstill renders its header and cells, and an undeclared type opens the same editor as no type
at all.
⛔ Not folded onto
'text': that would assert something false about alookupcolumn andleak the lie into any future reader. Absence says only what is true.
One addition beyond the four aliases the ruling names:
datetime-local→datetime.It is part of the same undeclared renderer dialect (it had its own editor branch at
:2273),so deleting that branch without the fold would silently drop behaviour. Repo precedent is
verbatim —
packages/types/src/ui-action.ts:199already documentsdatetime-local→datetimeas legacy-dialect resolution. Flagged here rather than done quietly.4. Second-producer census (step 4) — the card named one; there are two
TableColumn.type?plugin-grid/src/ObjectGrid.tsxgenerateColumns()(:1826,:1915,:1964,:2037) + thefieldDef.typeenrichment (:2236), all verbatimplugin-dashboard/src/ObjectDataTable.tsxenrich()spreads...fieldMeta, andbuildFieldMetareturnstype: overrides.type ?? meta?.type(recordFields.tsx:151) — the raw field type, same defectplugin-detail/src/RelatedList.tsxfieldType(a different key) +cell; itstype: def.typeis inside aFieldMetadata, not a columnplugin-dashboard/DashboardRenderer.tsx:758,DashboardGridLayout.tsx:328options.columnsthrough, elsederiveStaticTableColumnswhich writes notypePer the ruling ("a second producer found = the same normalization at its seam"),
ObjectDataTablegets the same fold.⭐ Why the ObjectGrid fold is a separate
.map()pass rather than folded into theenrichment map: that map early-returns for
_actionsand for any column whoseaccessorKeyhas no
fieldDef— and a heuristicinferColumnType()value (select,user) rides out onexactly those columns. Folding inside it would miss them. The seam test deliberately uses an
inline-data grid with no object schema, which is that path.
5. Ablations — direction and count predicted BEFORE running
Both run under the shared verify lock. Both mutations were proved on disk (grep counts of
the injected and removed text in both directions, plus a
git hash-objectbefore/afterchange) and both restores were proved byte-identical to the
HEADblob, not merelytrusted to the
EXITtrap.⭐ Neither needed a rebuild, and that is a measured claim, not an assumption:
vitest.config.mts:261aliases@object-ui/types→packages/types/src, so these suitesresolve the subject through source, not
dist— the stale-distfalse-green that makesan ablation vacuous cannot arise here.
z.string()money+ 7 out-of-union refusals)int,integer,float,double); controlnumberand theselectcolumn-survives case still passWhere my prediction was wrong. For ablation B I predicted the unfolded
intcolumn wouldopen a text input; the observed reading was
null. Cause: the built-in fallback<Input>renders with no
typeattribute rather thantype="text", so the probe readsnull.Direction and count were right; the predicted observable was not. The assertion still
separates folded from unfolded, which is what the ablation is for.
6. Gates — each with its own verdict line and exit code
Derived from the CI job step lists under
.github/workflows/. All run after the finalcommit, against
a2bae3d7f, on a clean tree.type-check(types, components, plugin-grid, plugin-dashboard)type-check: Done; each runstsc --noEmit && tsc -p tsconfig.test.json, so the new test files are type-checked toovitesttypes + plugin-grid + plugin-dashboardTest Files 230 passed (230)·Tests 2277 passed (2277)vitestcomponents (batch A: renderers/ui/notifications)Test Files 88 passed (88)·Tests 770 passed (770)vitestcomponents (batch B:src/__tests__)Test Files 103 passed (103)·Tests 968 passed (968)turbo run lint(4 packages + root)Tasks: 5 successful, 5 total— 0 errors (685+912+386+244+28 pre-existing warnings, none new: my 9 changed files reporterrors: 0)check-changeset-presence.mjs✅ 9 source file(s) of 4 released package(s) changed, and this change declares 1 changeset(s)check-changeset-no-major.mjs✅ No changeset declares a 'major' bump.check-changeset-fixed.mjs✅ All workspace packages are in the changeset fixed group.check-control-bytes.mjs✅ check-control-bytes: OK (scanned 5241 tracked text file(s))check:readme-exports✅ check-readme-exports: OK (… 378 self-imports judged (378 real, 0 wrong-path, 0 fabricated); 0 unbuilt)check:published-dist✅ No published package's build output carries tooling material.check:spec-symbols✅ spec symbol derivation: 1306 files scanned against 4959 spec export namescheck:self-import✅ No package names itself inside its own src/.check:esm-specifiersSpecifier leg: no un-ledgered package emits an extensionless relative specifier.check:doc-types✅ Every documented component type is registered.check:doc-snippetsSemantic phase: 267 of 267 block(s) judged, 0 failed.— the narrowing did not break a documented examplecheck:doc-fences✅ check:doc-fences — every TypeScript block in 223 document(s) is fenced …check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.lint:coverage✅ lint coverage: 46/46 packages linted, 0 with outstanding errorstype-check:coverage✅ test type-check coverage: 41/41 packages compile their testscheck:vi-mock-specifiers✅ check-vi-mock-specifiers: OK (3757 tracked source file(s))check:shell-escape-residue✅ check-shell-escape-residue: OK (4/4 root(s) resolved)turbo run build --filter='./packages/*'Tasks: 39 successful, 39 totalOn the components suite — it ran COMPLETE; the split is batching, not narrowing. A single
run hit the container's ~10-minute foreground cap (
exit 143). Rather than narrow coverage, Isplit it by directory. Population read from vitest's own config, not from my guess:
vitest list --filesOnly packages/componentsreports 191 files; batches A + B ran88 + 103 = 191. (A
findglob counted 193 — the two extras are.snapsnapshot files,not tests.)
check:readme-exportswas red on first run and that reading was NOT MEASURED, not afailure: its own message said
type entry ./dist/index.d.ts is not on disk -- run pnpm build firstfor all 236 items, none of them in a package I touched. After building all 39 packagesit is a real green with
0 unbuilt.Gates deliberately not run locally, as CI owns them:
test:e2e,test:e2e:live:ci,check:node-esm-load(full build leg; the--specifiers-onlyleg is green above),check:i18n-*,check:action-forward-parity,check:designer-field-key-parity,check:icon-record-names,check:skills-paths,type-check:scripts,type-check:vitest-setup— no file in this diff is an input to any of them.7. The pin that stops this regressing (T2)
#5684's guard is key-set only — it checks key presence, and
typewas present on bothsides the whole time, which is exactly how this instance survived while its siblings were
caught. The value-level pin has two halves:
packages/types/src/__tests__/table-column-type-canonical.test.ts— interface tuple ↔ zodenum parity, loud refusal (
moneyfails withtypein the error path), andnormalizeTableColumnTypetotality over all 49 spec field types.packages/components/src/renderers/complex/__tests__/table-column-type-read-set.test.tsx—the third end neither of those can see: every literal the renderer branches on, derived
from its source, must be a declared member. Carries blind-spot guards (no
switch, noincludes(editType), exactly one.has(editType)) so a hidden branch fails the instrumentinstead of silently under-counting it.
packages/plugin-grid/src/__tests__/columnTypeEmitSeam.test.tsx— the seam, pinnedbehaviourally through the editor a column actually opens, with a control probe.
⭐ A future new inference value turning these red is BY DESIGN, and the note at each pin
says so and names the two correct repairs (publish the value across all three ends, or fold it
at the producer) so the next reader does not "fix" it by loosening the mirror.
The zod mirror now builds its
z.enumfromTABLE_COLUMN_TYPESrather than restating themembers, so the types↔zod leg cannot drift structurally; the pins guard the legs that cannot
be derived.
Changeset
minorfor@object-ui/types(published-validator accept-set narrowing + new public API),patchfor the three others. Nevermajor—check-changeset-no-major.mjsgreen above.The text names the newly-refused spellings in two groups (typos/invented names; object-schema
field types written into a column slot) and states that in-repo authored metadata needs no
migration, with the §1 measurement behind it.
Generated by Claude Code