fix(types)!: narrow the body-wide record:details columns to the spec's string enum - #9041
Conversation
…c's string enum
`RecordDetailsComponentProps.columns` declared `columns?: number` while
`@objectstack/spec` declares the top-level key as
`z.enum(['1','2','3','4']).default('2')` — a closed set of string literals.
So `{ columns: 2 }` type-checked here and the contract refused it at publish
with `invalid_value` at `columns`: a green local build and a rejection at the
only point that matters.
Measured on the installed pin, @objectstack/spec 17.4.0, with a control that
fires on the same instrument — `{ columns: '2' }` parses green and its value
survives — and the declaration is byte-identical to the 17.3.0 the card was
filed against.
`sections[].columns` one level down is unchanged and stays `number`: the
per-section key is `z.number().int().min(1).max(4)`, so it takes `2` and
refuses `'2'`, exactly inverting the body-wide key. Both levels and their
non-equality are pinned against the installed spec in the new test.
The one in-repo caller writing the number is corrected in the same change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
✅ 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
|
Contract reviewPR #9041 · card #8604 · round 1 · head reviewed Charter read from objectstack Instruments (all mine; nothing taken from the PR text): a fresh blobless clone of objectui at the head; ① Derived judgments — every accept-set and public-surface change the diff produces
② Semver gradingChangeset ③ Boundary-flag disposition
Not measured here
Checks on the head
IndependenceImplemented-by: Charter basis (contract-review.md lines 33-37): the spec seat reviews through a context-isolated subagent at the review tier, fed only the card, the existing rulings and the PR itself; 「 Judgment: PASS. VERDICT: PASS Generated by Claude Code |
Provenance — carriers cleared, landingClause-② contract review: Reviewer ran at Independence — NOT self-review: Per Landing pre-checks — all measured just now
Two dev claims the review corrected — both recorded, neither blocking
Both carriers ( ⛔ Base is not being hand-merged. The merge queue rebuilds each PR on current Generated by Claude Code |
Fixes #8604
RecordDetailsComponentProps.columnsdeclaredcolumns?: number.@objectstack/specdeclares the same top-level key asz.enum(['1','2','3','4']).default('2')— a closed set of string literals. So{ columns: 2 }type-checked in this repo and the contract refused it at publish: a green local build and a rejection at the only point that matters.Direction is contract-first (Commandment #0.1, and the triage ruling on the card): the code moves to the contract's spelling. The spec is not widened, and the loose type is not kept to protect callers.
The measurement, re-derived on this branch with a firing control
Instrument: the installed published artifact,
@objectstack/spec17.4.0 (RecordDetailsPropsfrom@objectstack/spec/ui), resolved frompackages/types. The card was filed against 17.3.0 and the installed floor has since moved, so both declarations were re-derived by content.{ columns: 2 }invalid_valueat pathcolumns, "Invalid option: expected one of1/2/3/4"{ columns: '2' }— the controldata.columns === '2',typeofstring{ columns: '5' }{}data.columns === '2'from the schema default{ sections: [{ fields: ['a'], columns: 2 }] }2{ sections: [{ fields: ['a'], columns: '2' }] }invalid_typeatsections.0.columns, "expected number"{ sections: [{ fields: ['a'], columns: 5 }] }too_big, so the range really is 1-4The control is the point: a refusal with nothing that would have fired on the same instrument is indistinguishable from a schema that refuses everything.
The near-miss, and why a copy-paste fix would have been wrong
RecordDetailsProps.columns(top level)z.enum(['1','2','3','4']).default('2')'1' | '2' | '3' | '4'RecordDetailsProps.sections[].columns(per section)z.number().int().min(1).max(4).optional()numberThe two spellings are mutually exclusive in both directions, as the probe table shows. Copying either declaration onto the other is refused at publish.
Did 17.x move either declaration? No — measured by content, not from the changelog: the whole
RecordDetailsPropsblock is byte-identical between 17.3.0 (fetched from the registry for this comparison) and the installed 17.4.0 — 16891 bytes each, string-equal. The 17.4.0 changelog section additionally contains zero occurrences ofrecord:detailsorRecordDetails. So the card's 17.3.0 reading stands on the moved floor, and no floor bump is owed.Caller census
Two sweeps, unioned, then confirmed by the compiler.
record:details; every occurrence was scanned with a 48-line window forcolumns. Every hit is the per-section key or an unrelatedcolumns(a related list's field projection, a dashboard grid, a form). Zero top-level authoring sites. The synth producer confirms it independently:buildDefaultDetailsemits onlysectionsandhideFields, and thecolumnsit computes is written onto each section.RecordDetailsComponentProps. Exactly one wrote the top-level key:packages/types/src/__tests__/p1-spec-alignment.test.ts,columns: 2plus itsexpect(props.columns).toBe(2).columns: '2'in this PR. Census after the change: zero in-repo callers writing the number.TS2322atp1-spec-alignment.test.tsline 537) and nothing else outside the new pin file, which is the compiler agreeing with the grep.Packages: the narrowing is reachable only through the symbol, and the symbol is named in two packages —
@object-ui/types(declaration, re-export, tests) and@object-ui/plugin-detail(one type annotation on the renderer'sschemaprop). No other package names it, and no exported type embeds it, so no other package's type-check can move.@object-ui/plugin-detailtype-checks green against the narrowed face with its dependency closure built.Runtime: unchanged, and measured rather than assumed.
RecordDetailsRendererpasses the authored value straight through to the synthesizeddetail-viewnode, whereDetailSectionruns it throughMath.min(...)before every comparison. Feeding'2'and2through the real expressions produces byte-identical output at every step — sameeffectiveColumns, same grid class, same responsive span class. Nothing to migrate at runtime.Is there a zod mirror of this key?
No — searched by content, not by filename.
packages/types/src/zod/declares no schema for therecord:detailsprops bag: nosections+hideFields+inlineEditshape exists anywhere in the tree. The nearest neighbour isDetailViewSchemainzod/views.zod.ts, and it is a different surface — thedetail-viewnode this renderer synthesizes internally, whose owncolumns: z.number()is correct for it and which sits on norecord:detailsparse path (its only consumers are the package's export list and one unit test).The third declared face is already correct and worth naming, because it corroborates the direction:
@object-ui/plugin-detail's registry manifest publishes this key as{ name: 'columns', type: 'enum', enum: ['1','2','3','4'] }. The published TypeScript face was the only layer that disagreed.Docs sweep
Nothing to change, and nothing held is blocked by this PR. Every numeric
columnsliteral undercontent/docs/was enumerated and read in context: they belong toform,object-form,dashboard,gridanddetail-view— never to arecord:detailsblock. Only one document mentionsrecord:detailsat all (content/docs/guide/react-pages.md), and it does not mention itscolumns.Specifically on the held file:
content/docs/api/schema-reference.md(held by #9021) carries norecord:detailssection whatsoever, so the doc row this sweep was told to expect does not exist there. Reported, not edited, as instructed. No held file needs to move for this change.Changeset level
'@object-ui/types': minor, with BREAKING and a FROM/TO in the body.Precedent checked by content rather than guessed, and it is close to exact:
.changeset/retire-record-details-section-collapsed.mdnarrows this same interface (removingsections[].collapsed), opens with**BREAKING**, spells out FROM/TO, and declaresminor.AGENTS.mdstates the governing convention — this repo's fixed version group tracks@objectstack's major, so objectui's own breaking changes are declaredminorwith the breaking semantics written into the body, andscripts/check-changeset-no-major.mjsenforces it mechanically. Both changeset gates run green here (check-changeset-presenceandcheck-changeset-no-major).Verification
All commands run on this branch, at
399ebdc5.pnpm --filter @object-ui/types test— exit 0, 170 files / 3357 tests passed (includes the new pin file).pnpm --filter @object-ui/types type-check— exit 0 (tsc --noEmit+tsconfig.examples.json+tsconfig.test.json).pnpm --filter @object-ui/types lint— exit 0, its own verdict line reads278 problems (0 errors, 278 warnings); the warnings are the package's pre-existingno-explicit-anypopulation.pnpm --filter '@object-ui/plugin-detail^...' buildthenpnpm --filter @object-ui/plugin-detail type-check— exit 0. The first attempt without the closure wasexit 2withTS2307 Cannot find module '@object-ui/core': a prerequisite failure, NOT MEASURED, not a red gate.check:control-bytes,check:doc-types,check:spec-symbols,check:new-line-citations,check:unreferenced-sources— all exit 0. Plus a manual control-byte scan of the three changed files.check:doc-examples,check:doc-snippets,check:spec-floors— exit 2 / 2 / 1, all PRECONDITION NOT MET: each printed that the packages it judges are not built and that it did not run. Read as NOT MEASURED, deferred to CI, which builds the workspace.eslint . --no-inline-config --format jsonat the repo root, run to completion: population 4755 files (read from eslint's own config resolution, not estimated), of which my three changed files are three. Those three carry 0 errors. The 95 errors elsewhere are pre-existing on the base and are inline-disabled in the real per-package runs, which is why the package's ownlintis exit 0.Ablation — the pins can fail. Committed first, then the narrowing alone was reverted to
columns?: numberon disk (mutation proven on disk before running: the narrowed spelling went 1 to 0 occurrences, the reverted spelling 0 to 1, and the blob hash moved off the HEAD blob), atraprestored it, and the restore was proven byte-identical afterwards —git diff HEADempty and the file's hash back to the HEAD blob. With the narrowing reverted,type-checkgoes exit 2 with five distinct failures:Line 70 is the parity assertion against the spec's own
z.inputtype; line 82 is the near-miss assertion that the two levels are not the same type; line 96 is the@ts-expect-erroroncolumns: 2going unused, which is the compile-time half of the fix. The direction observed is the expected one: turns red.tscactually sees it. Verified withtsc --listFiles: the file appears in thetsconfig.test.jsonprogram (1 hit) and in neither of the other two, andtype-checkruns that config. Measured, not assumed.Out-of-scope findings
Filed as #9040 —
RecordDetailsComponentProps's top level diverges from the contract in both directions, one key over from this card and one level up from #8583: it declareslayout(which the spec refuses by name as aretiredKey, measuredinvalid_typewith the removal prescription as its message, against a control loop where every other top-level key accepts a plausible value), and it omitshideFields/inlineEdit/showHeader, which the spec declares, the renderer honours and the registry manifest already publishes. Filed rather than carried here:layoutis a retirement with its own conversion obligations, the three omissions are additive design, and this card is scoped to one key. Dedup ran on a targeted search that returned 35 on-topic neighbours — the neighbours are the control that the query was live.验收备注
needs:contract-reviewis hung on this PR as well as on the card. The dispatch brief said not to touch labels; this repo's own half-state sweeper overrides that for this one label, and says so explicitly:scripts/pm/check-half-states.mjsfinding H31 documents it as a dual-carrier gate (maintainer ruling 2026-08-22, 「两边都挂好」) whose two carriers are hung and cleared in one stroke, and fires precisely when the card carries it and the PR does not. Opening this PR bare would have manufactured the half-state that check exists to catch. Written additively, then read back. No other label was touched.Clause-②: yes. The pairing-exit-code check the dispatch protocol asks for is anobjectstack-side tool; this repository has no--pairmode —scripts/pm/check-half-states.mjsis a report-only sweeper with no such flag. The gate is carried by the two labels instead.content/docs/api/schema-reference.mddocumentsdetail-viewbut has norecord:detailssection at all, so the block's whole authoring surface is undocumented in the API reference. That is an absence, not an error, and the file is held by fix(types): ObjectKanbanSchema.groupBy is optional, as the protocol declares it (objectui#8990) #9021.detail-viewzod mirror (DetailViewSchema.columns: z.number()) receives the authoredrecord:detailsvalue, which is now typed as a string. It is inert — the synthesized node is never parsed by that mirror, and the renderer coerces identically either way (measured above) — so there is no failing path to file.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
Generated by Claude Code