Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/6625-retire-fieldmeta-decimals.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/plugin-dashboard': minor
---

Retire `FieldMeta.decimals` and the now-unfed `BuildFieldMetaParams.overrides.decimals`
from `plugin-dashboard`'s shared field-rendering helpers (objectui#6625,
enforce-or-remove).

`buildFieldMeta` computed `decimals: overrides.decimals ?? meta?.decimals ?? meta?.scale`
on **every** call and the value **reached nothing**. Re-measured on this branch's base
(`efdc6c62`): **zero `.decimals` member reads** across `@object-ui/fields`,
`@object-ui/i18n`, `@object-ui/components`, `@object-ui/core` and `plugin-dashboard`
itself — the only non-comment occurrence was the write being removed here. The
**positive control in the same query shape** fires: `.scale` member reads hit
`NumberField.tsx`, `GridField.tsx` and `fields/src/index.tsx`. So the zero is a
finding, not a broken query. The `overrides.decimals ??` head of that chain had already
lost its only feeder when objectui#6425's ruling removed the authored read from
`ObjectDataTable.enrich()`; `RecordDetailDrawer`, the only other `buildFieldMeta`
caller, passes no overrides at all. Both halves retire together, so the key leaves in
one move.

Behaviour is unchanged — no reader existed to notice, pinned by the unchanged runtime
assertion in `ObjectDataTable.overrideSource-6425.test.tsx` that an authored `decimals`
renders byte-identical to its absence.

**The refusal did NOT leave with the member.** `ObjectDataTable` derives *two* refusal
bands from `keyof FieldMeta` — `EnrichedColumn`'s write-side tombstones (objectui#6373)
and `AuthoredColumnOverrides`' read-side band (objectui#6425) — so deleting the member
would have dropped `decimals` from both as a side effect, silently un-enforcing
objectui#6425's retire. A new hand-written `ObjectDataTableRetiredDecimalsTombstone`
(`{ decimals?: never }`) is intersected into both halves of the seam, the same shape and
for the same reason as `ObjectGrid`'s `ObjectGridRetiredOptionsTombstone`. The verdict
is unchanged since 2026-08-27; only the artefact enforcing it moved, from derived to
hand-written.

Marked `minor` per this repo's version-alignment rule (AGENTS.md 版本号策略), which
reserves `major` for following `@objectstack` across a major. Scope note, measured rather
than assumed: `FieldMeta`, `BuildFieldMetaParams`, `AuthoredColumnOverrides` and
`EnrichedColumn` are **absent from `dist/index.d.ts`** — they are not re-exported by the
package barrel, and the `exports` map publishes only `"."`, so no consumer can name them.
No downstream type moves; this is a package-internal contract change.
80 changes: 70 additions & 10 deletions packages/plugin-dashboard/src/ObjectDataTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,14 +131,52 @@ interface NormalizedColumn {
* seam instead of arriving anonymously inside a spread. When #5120 retires the
* consumer alias, this member becomes a tombstone with it.
*/
/**
* ⭐ `decimals` — RETIRED from `FieldMeta` ITSELF (objectui#6625), and this
* hand-written tombstone is now the ONLY enforcement of that key's refusal at
* BOTH halves of this seam. ⛔ Do not "tidy" it away as redundant.
*
* Until objectui#6625 the key needed no tombstone: it was a `FieldMeta` member,
* so BOTH bands below — {@link EnrichedColumn}'s write-side tombstones
* (objectui#6373) and {@link UnheldFieldMetaOverrideKey}'s read-side refusal
* (objectui#6425) — derived it for free. Refusal by MEMBERSHIP. #6625 then
* retired the member (written from the schema def, read by nothing), which
* removed it from `keyof FieldMeta` and silently ended that enforcement at both
* ends. Nothing would have gone red at the moment of loss: on the read side the
* suite's `@ts-expect-error` would merely have turned TS2578-unused, and on the
* write side the hand-written pin would have gone on passing on the
* excess-property check alone — a pin passing because its subject stopped
* existing.
*
* This is the exact mirror of the rule `ObjectGrid`'s sibling tombstone
* records (`ObjectGridRetiredOptionsTombstone`, objectui#6425): *a pin enforced
* by a key's non-membership silently stops enforcing the moment the key becomes
* a member.* Read from this end: **a refusal DERIVED from a key's membership
* silently stops enforcing the moment that member is deleted.** Same seam, same
* blindness, opposite direction — which is why the retirement had to be
* re-stated by hand rather than inherited.
*
* objectui#6425's verdict is unchanged — no reader for an authored `decimals`
* existed then and none exists now — and so is the runtime behaviour: the value
* the retired `buildFieldMeta` write resolved reached nothing. Only the
* refusal's mechanism moves, from derived to hand-written. It is intersected
* into BOTH types below, because the retirement belongs to the SEAM, not to one
* of its two halves — the same reason `ObjectGrid` intersects its own tombstone
* into both its draft and its post-fold column. ⭐ A future reader for decimal
* places reads `scale`, never this key (objectui#6625; `NumberCellRenderer`
* already does).
*/
type ObjectDataTableRetiredDecimalsTombstone = { decimals?: never };

export type EnrichedColumn =
TableColumn
/** HELD alias, objectui#5120 — see above. Not declared by `TableColumn`. */
& { name?: string }
/** RETIRED at this emit seam, objectui#6373 — derived, never hand-listed, so
* a future `FieldMeta` member is tombstoned by default and has to be
* adjudicated to escape. */
& { [K in Exclude<keyof FieldMeta, keyof TableColumn | 'name'>]?: never };
& { [K in Exclude<keyof FieldMeta, keyof TableColumn | 'name'>]?: never }
& ObjectDataTableRetiredDecimalsTombstone;

/**
* What this widget's column producer is allowed to READ off the AUTHORED
Expand DownExpand Up@@ -167,8 +205,10 @@ export type EnrichedColumn =
* - `currency` — DECLARED (kept in production, never promised before;
* declaring makes the existing behaviour honest). Same `Pick`.
* - `decimals` — RETIRED, immediately (neither promised nor kept: zero
* readers measured, and the authored read below is gone). The derived
* band now refuses it like any other unadjudicated `FieldMeta` member.
* readers measured, and the authored read below is gone). Refused by
* {@link ObjectDataTableRetiredDecimalsTombstone} since objectui#6625
* retired the `FieldMeta` member itself and took the key out of the derived
* band's pool; the verdict is unchanged, only its mechanism moved.
* - `referenceTo` — ⛔ NOT declared as spelled; still HELD, owned by
* objectui#6597 (the enforce-or-remove channel: fix the spelling chain so
* the promise becomes real, or withdraw the README line).
Expand All@@ -182,8 +222,12 @@ export type EnrichedColumn =
* `FieldMeta` is the override vocabulary — `buildFieldMeta`'s `overrides` is a
* subset of it — so it is the pool a new tolerance would come from, and the
* same pool {@link EnrichedColumn}'s tombstones derive from. Deriving means a
* seventh `FieldMeta` member is refused here on the day it is added, without
* anyone remembering to extend a hand-written list.
* NEW `FieldMeta` member is refused here on the day it is added, without
* anyone remembering to extend a hand-written list. (Stated without a count on
* purpose: it read "a seventh" while the type had eight members, and
* objectui#6625 has since retired one. The property is that ADDING is covered
* by derivation — ⚠️ REMOVING is not, which is why that card had to leave
* {@link ObjectDataTableRetiredDecimalsTombstone} behind by hand.)
*
* ⚠️ It derives from the OVERRIDE VOCABULARY, not from the authored input
* type, and that difference is forced rather than stylistic. `plugin-grid`'s
Expand DownExpand Up@@ -230,7 +274,9 @@ export type EnrichedColumn =
* left: objectui#6425's ruling (maintainer, 2026-08-27) declared `format` /
* `options` / `currency` on `TableColumn` itself (they are read via
* `Pick<TableColumn, …>` below, no hold needed) and retired `decimals`
* outright (the derived band refuses it now).
* outright — refused by {@link ObjectDataTableRetiredDecimalsTombstone} since
* objectui#6625 retired the `FieldMeta` member that used to carry it into the
* derived band.
*/
export interface ObjectDataTableColumnHolds {
/**
Expand All@@ -253,9 +299,20 @@ export interface ObjectDataTableColumnHolds {
* {@link ObjectDataTableColumnHolds} to escape. Keys `TableColumn` declares
* leave the pool by declaration: `type` (objectui#5853 owns its VALUE set,
* folded below by `normalizeTableColumnType`) and, since objectui#6425's
* ruling, `format` / `options` / `currency`. `decimals` is the member the
* same ruling RETIRED — it lands here, refused at the read site, which is
* exactly the "held-band verdict flips to RETIRED" the ruling asked for.
* ruling, `format` / `options` / `currency`.
*
* ⚠️ `decimals` USED TO land here — that is how objectui#6425's ruling was
* enforced at the read site. It no longer can: objectui#6625 retired the
* `FieldMeta` member itself, so the key is out of this Exclude's POOL rather
* than out of its exclusion list, and a derived band cannot refuse a key that
* is not in what it derives from. The refusal is carried by
* {@link ObjectDataTableRetiredDecimalsTombstone} instead, intersected below.
* ⛔ Do not read this band's silence about `decimals` as a softening — the two
* artefacts together are the same verdict, unchanged since 2026-08-27.
*
* The pool is what shrank, so what THIS band still refuses is `name` and
* `label` — both of them `FieldMeta` members with answers this seam already
* has (see the docblock above).
*/
export type UnheldFieldMetaOverrideKey =
Exclude<keyof FieldMeta, keyof TableColumn | keyof ObjectDataTableColumnHolds>;
Expand All@@ -271,7 +328,10 @@ export type AuthoredColumnOverrides =
* their published types, straight off the declaration. */
& Pick<TableColumn, 'format' | 'options' | 'currency'>
& ObjectDataTableColumnHolds
& { [K in UnheldFieldMetaOverrideKey]?: never };
& { [K in UnheldFieldMetaOverrideKey]?: never }
/** RETIRED, objectui#6425's verdict — re-stated by hand because objectui#6625
* took the key out of the derived band's pool. See the tombstone's docblock. */
& ObjectDataTableRetiredDecimalsTombstone;

/**
* Shared empty fallback for the resolved row list (objectui#4629).
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -210,10 +210,14 @@ describe('ObjectDataTable emits only what the columns slot declares (#6373)', ()
it('names the six keys that retired, and keeps the one that is held', async () => {
const cols = await emit({ type: 'object-data-table', objectName: 'account' });
for (const col of cols) {
// `Object.keys`, not a value read: `buildFieldMeta` always returns all
// eight members, so before this card every one of these keys EXISTED on
// every emitted column — carrying `undefined` where the schema said
// nothing, which is its own small lie about the shape.
// `Object.keys`, not a value read: `buildFieldMeta` returns all of its
// members unconditionally, so before this card every one of these keys
// EXISTED on every emitted column — carrying `undefined` where the schema
// said nothing, which is its own small lie about the shape. (It returned
// eight then and returns seven now: objectui#6625 retired `decimals` from
// `FieldMeta` itself, so that key can no longer be written from here even
// by accident. The verdict below is unchanged — it was already retired
// from the EMIT by this card.)
for (const key of RETIRED) {
expect(Object.keys(col), `${col.accessorKey}.${key}`).not.toContain(key);
}
Expand DownExpand Up@@ -296,12 +300,15 @@ describe("the emit type can FAIL — otherwise the annotation is decoration (#63
// — so on its own it pins "the spread is refused" without pinning WHY, and
// would have gone on passing after the enforcement was removed.
//
// `Omit<FieldMeta, 'name' | 'type'>` spans the retired six: `name` is
// `Omit<FieldMeta, 'name' | 'type'>` spans the retired members: `name` is
// the held alias, `type` carries #5853's own refusal. Since the #6425
// ruling declared `format` / `options` / `currency` on `TableColumn`,
// the members still refused by tombstones are `label`, `referenceTo` and
// `decimals` — enough to keep this spread an error, and nothing but the
// tombstones refuses it.
// ruling declared `format` / `options` / `currency` on `TableColumn`, the
// members still refused by the DERIVED tombstones are `label` and
// `referenceTo` — enough to keep this spread an error, and nothing but the
// tombstones refuses it. (`decimals` used to be a third; objectui#6625
// retired the `FieldMeta` member, so it is no longer spanned by this
// `Omit` at all. Its own refusal is pinned separately below, because a
// hand-written tombstone is now what carries it.)
// @ts-expect-error objectui#6373 — the still-tombstoned members are refused by the tombstones alone.
const retiredRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', ...({} as Omit<FieldMeta, 'name' | 'type'>) };
expect(retiredRefused.accessorKey).toBe('a');
Expand All@@ -313,9 +320,36 @@ describe("the emit type can FAIL — otherwise the annotation is decoration (#63
// by different machinery. This pin carried `format` until the #6425
// ruling declared it (writing it by hand is now legal, see the accepted
// fixture above); `decimals` — the key the same ruling RETIRED — takes
// its place, refused by the derived tombstone.
// @ts-expect-error objectui#6373/#6425 — `decimals` retired from this emit seam.
const writtenRefused: EnrichedColumn = { header: 'h', accessorKey: 'a', decimals: 2 };
// its place.
//
// ⚠️ The source is a VARIABLE, not a fresh object literal, and that is
// objectui#6625's doing rather than style. A fresh literal is refused by
// the excess-property check whether or not any tombstone exists, so as a
// literal this pinned "something refused this" without pinning what — and
// it would have gone on passing after #6625 removed `decimals` from
// `FieldMeta` and thus from the DERIVED tombstone band, which is a pin
// passing because its subject stopped existing. A non-fresh source skips
// the freshness check and reaches
// `ObjectDataTableRetiredDecimalsTombstone` and nothing else. The control
// below proves that is what answers.
const carriesDecimals: { header: string; accessorKey: string; decimals?: number } =
{ header: 'h', accessorKey: 'a', decimals: 2 };
// @ts-expect-error objectui#6373/#6425/#6625 — `decimals` refused by the explicit retired-key tombstone.
const writtenRefused: EnrichedColumn = carriesDecimals;
expect(writtenRefused.accessorKey).toBe('a');
});

it('the TOMBSTONE is what refuses the retired `decimals` at this emit', () => {
// ⭐ objectui#6625's counter-control for the pin above: `EnrichedColumn`
// minus the retired-key tombstone and nothing else ACCEPTS the very source
// the directive refuses. So the refusal is the tombstone's — not the
// derived band's (which no longer reaches `decimals`, since the key left
// `keyof FieldMeta`), not the excess-property check's (the source is not
// fresh), and not weak-type detection's (`header` / `accessorKey` are in
// common, and both are required here).
const carriesDecimals: { header: string; accessorKey: string; decimals?: number } =
{ header: 'h', accessorKey: 'a', decimals: 2 };
const untombstoned: Omit<EnrichedColumn, 'decimals'> = carriesDecimals;
expect(untombstoned.accessorKey).toBe('a');
});
});
Loading
Loading