From 7511392ff5c13ffc88aa20740932b06feef33b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:03:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(types)!:=20retire=20DataTableSchema.toolba?= =?UTF-8?q?r=20=E2=80=94=20declared=20everywhere,=20read=20nowhere=20(obje?= =?UTF-8?q?ctui#6881)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-08-31 (option A: retire, do not wire): the key was declared on both published faces, documented, mirrored — and no renderer ever mounted it. An authored toolbar got a green document and a blank result. Both faces tombstoned in the same stroke, per the house ADR-0049 convention: - TS: `toolbar?: never` (deletion would fall through to BaseSchema's `[key: string]: any` and be silently admitted again) - Zod: `retirementTombstone()` — refusal carries the remediation (use the built-in chrome: searchable / exportable) The mirror-wider-than-declared drift (single-node spelling only the mirror accepted) dies with the key: both faces now agree by refusing both spellings. Pinned in data-table-toolbar-retired.test.ts: envelope-asserted refusal on both spellings, remediation text derived-equal with .describe(), absent stays valid, sibling emptyAction still accepted (counter-probe), key stays declared (route guard), plus the compile-time half via @ts-expect-error in the type-checked test program. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6881-retire-data-table-toolbar.md | 60 ++++++ .../data-table-toolbar-retired.test.ts | 194 ++++++++++++++++++ packages/types/src/data-display.ts | 26 ++- packages/types/src/zod/data-display.zod.ts | 2 +- 4 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 .changeset/6881-retire-data-table-toolbar.md create mode 100644 packages/types/src/__tests__/data-table-toolbar-retired.test.ts diff --git a/.changeset/6881-retire-data-table-toolbar.md b/.changeset/6881-retire-data-table-toolbar.md new file mode 100644 index 0000000000..9ec843168d --- /dev/null +++ b/.changeset/6881-retire-data-table-toolbar.md @@ -0,0 +1,60 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED +(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors +`toolbar` no longer validates — the parse fails loudly on the `toolbar` path +with the remediation in the message — and the TS member is a `?: never` +tombstone, so the same document is refused at compile time. + +**What was measured.** The key was declared on both published faces — +`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content") +and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and +read by NOTHING: `data-table.tsx`, the registered renderer for +`type: 'data-table'`, contains the word only in two prose comments and never +reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS +mounted through `SchemaRenderer`, so the census zero is a reading, not a blind +query. An author who wrote a toolbar got a green document and a blank result, +with no signal anywhere that said so — the declared-vs-enforced failure mode +that is worst for AI-authored metadata, which has nothing but the declaration +to go on. + +**Who is affected — a `toolbar` authored directly onto a `data-table` node, +in either spelling:** + +```json +{ "type": "data-table", + "columns": [{ "header": "Name", "accessorKey": "name" }], + "data": [], + "toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing +``` + +now fails validation with: + +> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the +> built-in toolbar chrome (searchable / exportable), or compose nodes beside +> the table + +The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever +accepted; the TS face always refused it — is refused the same way, so the two +faces now agree by refusing both. + +**Who is NOT affected.** A document that never wrote the key is untouched +(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction` +included — is unchanged. No fixture, example, catalog entry, doc page or app +in this repository authored the key (measured: all five +`components-complex-data-table` catalog schemas are toolbar-free, and every +other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an +unrelated React prop of the same name). + +**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`), +or compose your own nodes beside the table. Per the ruling, a real toolbar +slot must arrive as a redesigned proposal WITH its enforcing reader — published +zero-consumer capability gets no sunk-cost exemption. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated key. It is not `major` per +this repo's fixed-group convention (objectui's own breaking changes ship as +`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略, +mechanically enforced by `scripts/check-changeset-no-major.mjs`). diff --git a/packages/types/src/__tests__/data-table-toolbar-retired.test.ts b/packages/types/src/__tests__/data-table-toolbar-retired.test.ts new file mode 100644 index 0000000000..d9ac8c96e2 --- /dev/null +++ b/packages/types/src/__tests__/data-table-toolbar-retired.test.ts @@ -0,0 +1,194 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — `DataTableSchema.toolbar` is REFUSED, not silently ignored + * (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire). + * + * ## The failure this pin exists to prevent + * + * `toolbar` was declared on both published faces — `data-display.ts` and the + * Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx` + * never mounts it (the word appears there only in two prose comments), while + * the sibling `emptyAction` slot on the SAME interface is mounted through + * `SchemaRenderer`, so the census zero is a reading, not a blind query. An + * author who wrote a toolbar got a green document and a blank result, with no + * signal anywhere that said so — the declared-vs-enforced failure mode, worst + * for AI-authored metadata, which has nothing but the declaration to go on. + * + * So the deliverable is not "toolbar renders". It is: **an authored `toolbar` + * is refused loudly at the authoring boundary**, with the remediation in the + * refusal (the built-in chrome: `searchable` / `exportable`). The ruling + * records that a real toolbar slot must arrive as a redesigned proposal WITH + * its enforcing reader — not by reviving this key. + * + * ## Why the tombstone, and not simply deleting the key + * + * `BaseSchema` is `.passthrough()` on the Zod side and carries a + * `[key: string]: any` index signature on the TS side. An UNDECLARED key is + * accepted by both halves, unvalidated — deleting `toolbar` outright would + * hand the authored spelling exactly the silent no-op this card exists to + * close. `?: never` / `retirementTombstone()` is this package's convention — + * {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm` + * (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is + * lockstep: both halves or neither. + * + * ## The rider the retirement settles by construction + * + * Before this card the two faces disagreed on the SHAPE: TS said + * `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a + * mirror-wider-than-declared drift no parity ledger watches. Retiring both + * faces in the same stroke makes them agree by refusing BOTH spellings, so + * this file pins the single-node spelling refused too — the half only the + * mirror ever accepted. + */ + +import { describe, it, expect } from 'vitest'; +import { DataTableSchema } from '../zod/data-display.zod.js'; +import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js'; + +const GUIDANCE = + 'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'; + +/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */ +const VALID_TABLE = { + type: 'data-table', + columns: [{ header: 'Name', accessorKey: 'name' }], + data: [], +} as const; + +describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => { + it('REFUSES the array spelling, naming the retired key', () => { + // The pin. Before the retirement this document parsed GREEN (`toolbar` was + // `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`), + // measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE — + // not merely `success:false` — so the pin cannot be satisfied by an + // unrelated rejection. + const result = DataTableSchema.safeParse({ + ...VALID_TABLE, + toolbar: [{ type: 'button', label: 'Refresh' }], + }); + expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === 'toolbar'); + expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy(); + expect(issue?.code).toBe('invalid_type'); + expect((issue as { expected?: string } | undefined)?.expected).toBe('never'); + }); + + it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => { + // Pre-retirement, TS refused this spelling while the mirror admitted it + // (the drift recorded on objectui#6881 as the secondary observation). The + // two faces now agree by refusing both. + const result = DataTableSchema.safeParse({ + ...VALID_TABLE, + toolbar: { type: 'button', label: 'Refresh' }, + }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => i.path[0] === 'toolbar'); + expect(issue?.code).toBe('invalid_type'); + }); + + it('the refusal CARRIES the remediation text, not zod\'s generic message', () => { + // `retirementTombstone()` writes the guidance once into the parse message + // and `.describe()` both (objectui#6931) — the author is told what to + // write instead: the built-in chrome. + const result = DataTableSchema.safeParse({ + ...VALID_TABLE, + toolbar: [{ type: 'button', label: 'Refresh' }], + }); + expect(result.success).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === 'toolbar'); + expect(issue?.message).not.toContain('Invalid input: expected never, received '); + expect(issue?.message).toBe(GUIDANCE); + // ONE string, BOTH channels — asserted derived, so parse message and + // generated-docs metadata cannot drift apart. + expect(issue?.message).toBe( + (DataTableSchema.shape.toolbar as { description?: string }).description, + ); + }); + + it('leaves a document that never wrote the key untouched — the inside of the boundary', () => { + // `absent` stays valid — `.optional()` on the tombstone. The retirement + // narrows exactly one key and nothing else. + expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true); + }); + + it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => { + // `emptyAction` is the slot the census used as its positive control: same + // interface, same SchemaNode shape, actually mounted (data-table.tsx, via + // SchemaRenderer). Without this leg the refusals above would be satisfied + // by a schema that refuses every SchemaNode slot — a narrowing that + // refuses too much would pass a refusal-only test. + const result = DataTableSchema.safeParse({ + ...VALID_TABLE, + emptyAction: { type: 'button', label: 'New' }, + }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => { + // The route guard. `BaseSchema` is `.passthrough()`, so removing the key + // from the mirror would make the authored spelling parse green again and + // do nothing — the silent no-op reintroduced by the very edit meant to + // remove it. + expect( + Object.keys(DataTableSchema.shape), + 'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again', + ).toContain('toolbar'); + }); +}); + +describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => { + it('refuses the retired key at compile time', () => { + // On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the + // assignment is LEGAL, the directive below is unused, and `tsc` fails the + // build with TS2578 naming the key — this leg is red before the fix in + // `type-check`, not in vitest, which strips types. + + // @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable. + const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }]; + + // Counter-probe on the same surface: the sibling SchemaNode slot still + // accepts a node, so the directive above pins the KEY's retirement and not + // a blanket narrowing of the interface. + const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' }; + + expect([retired, sibling]).toHaveLength(2); + }); + + it('refuses the retired key in the form authors actually write', () => { + // The leg that proves the tombstone survives `BaseSchema`'s + // `[key: string]: any`: if the index signature won, `toolbar` would widen + // back to `any` here and the directive would go unused (TS2578). + const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }]; + + const retiredDocument: DataTableSchemaTS = { + type: 'data-table', + columns, + data: [], + // @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`). + toolbar: [{ type: 'button', label: 'Refresh' }], + }; + + // The migrated document — built-in chrome instead — still type-checks. + const migratedDocument: DataTableSchemaTS = { + type: 'data-table', + columns, + data: [], + searchable: true, + exportable: true, + }; + + expect([retiredDocument, migratedDocument]).toHaveLength(2); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index ab0266716c..6d758464e6 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema { */ caption?: string; /** - * Table toolbar actions/content + * ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer + * ruling 2026-08-31: retire, do NOT wire). + * + * What was measured (objectui#6881, re-measured on the retiring PR's base): + * declared on both published faces, documented, mirrored — and read by + * NOTHING. `data-table.tsx`, the registered renderer for `type: + * 'data-table'`, contains the word only in two prose comments and never + * reads `schema.toolbar`; the sibling `emptyAction` slot on this same + * interface IS mounted through `SchemaRenderer`, so the zero is a reading, + * not a blind query. An author who wrote a toolbar got a green document and + * a blank result, with no signal anywhere that said so. + * + * `?: never` is this package's tombstone convention (see `crud.ts` + * `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT + * a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key + * as `any` again — the same silence one layer over. The Zod twin refuses it + * loudly via `retirementTombstone()` (`zod/data-display.zod.ts`). + * + * RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table + * renderer. Use the built-in toolbar chrome instead (`searchable` / + * `exportable`), or compose your own nodes beside the table. A real + * toolbar slot must arrive as a redesigned proposal WITH its enforcing + * reader, per the ruling — not by reviving this key. */ - toolbar?: SchemaNode[]; + toolbar?: never; /** * Table columns */ diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index cb37025b8b..9b0b8dc140 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({ type: z.literal('data-table'), caption: z.string().optional().describe('Table caption'), borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'), - toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'), + toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'), columns: z.array(TableColumnSchema).describe('Table columns'), data: z.array(z.any()).describe('Table data'), pagination: z.boolean().optional().describe('Enable pagination'),