diff --git a/.changeset/6105-tombstone-refusal-message.md b/.changeset/6105-tombstone-refusal-message.md new file mode 100644 index 0000000000..6e21b25b79 --- /dev/null +++ b/.changeset/6105-tombstone-refusal-message.md @@ -0,0 +1,29 @@ +--- +'@object-ui/types': patch +--- + +Static-table retirement tombstones now refuse with their remediation text +(objectui#6105). + +The nine ADR-0049 tombstones on `StaticTableColumnSchema` (`minWidth`, `align`, +`fixed`, `type`, `sortable`, `filterable`, `resizable`, `editable`, `cell`) +already refused an authored value at the right path — but the carefully written +`.describe()` string never reached the author, because `.describe()` is schema +METADATA. What an author saw was zod's own `Invalid input: expected never, +received string`: which key is wrong, nothing about why it was retired or what +to write instead. Loud refusal is the ruled outcome; half its payload was being +dropped. + +One shared mechanism carries the text into both channels. `retirementTombstone()` +(`zod/tombstone.zod.ts`) takes the guidance string ONCE and writes it to both +`z.never({ error })` — the parse-time issue message — and `.describe()` — the +generated JSON-Schema and docs surface, unchanged. One string, so the two cannot +drift. + +Authoring `align: 'right'` on a static table column now reports `RETIRED +(objectui#5474) — never read by the static table; use data-table, or a +cellClassName like text-right`. + +The accept set is untouched: same `success`, same issue `path`, same issue `code` +(`invalid_type`) for all nine, measured member-by-member before and after. Only +the message differs. diff --git a/packages/types/src/__tests__/static-table-narrow-surface.test.ts b/packages/types/src/__tests__/static-table-narrow-surface.test.ts index b59508a5b4..8330d319d3 100644 --- a/packages/types/src/__tests__/static-table-narrow-surface.test.ts +++ b/packages/types/src/__tests__/static-table-narrow-surface.test.ts @@ -224,6 +224,112 @@ describe('static `table` — the narrow zod surface refuses the retired keys (ob }); }); +/* ── 1b. the refusal CARRIES the remediation text ────────────────────────── */ + +/** The nine keys the #5474 split retired — the set objectui#6105 converted to + * `retirementTombstone()`. NOT the whole tombstone population of this shape: + * the five later arrivals (#6424 / #6425) are pinned as the scope boundary + * below, still carrying zod's generic message. */ +const SIX105_CONVERTED = [ + 'minWidth', 'align', 'fixed', 'type', 'sortable', + 'filterable', 'resizable', 'editable', 'cell', +] as const; + +/** zod's own message for a `z.never()` with no custom error — the string this + * card exists to replace. Matched as a PREFIX because the tail names the + * received type (`… received string` / `… received boolean`). */ +const ZOD_GENERIC_NEVER = 'Invalid input: expected never, received '; + +const describeOf = (schema: unknown, key: string): string | undefined => + (shapeOf(schema)[key] as { description?: string } | undefined)?.description; + +describe('the tombstone refusal reaches the author with its remediation text (objectui#6105)', () => { + it('the nine #5474 tombstones each answer with their own guidance, not zod\'s generic message', () => { + // Non-vacuity control, IN THIS TEST: a fully-live column must parse GREEN + // in the same run. Without it a schema that refused everything — or a + // broken reader returning no issues at all — would satisfy every + // assertion below by accident. + expect(StaticTableColumnSchema.safeParse(LIVE_COLUMN).success).toBe(true); + + for (const key of SIX105_CONVERTED) { + const result = StaticTableColumnSchema.safeParse({ + header: 'Amount', + accessorKey: 'amount', + [key]: RETIRED_COLUMN_KEYS[key], + }); + expect(result.success, key).toBe(false); + if (result.success) continue; + + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + + // The message is the payload this card is about. + expect(issue!.message, key).not.toContain(ZOD_GENERIC_NEVER); + expect(issue!.message, key).toContain('RETIRED (objectui#5474)'); + expect(issue!.message, key).toContain('use data-table'); + + // BOTH channels, one string: the runtime message and the `.describe()` + // metadata that feeds generated JSON-Schema/docs are the SAME text. This + // is the invariant `retirementTombstone()` exists to make unbreakable — + // asserted derived (no hand-copied literal to rot), which is why the two + // literal anchors above sit beside it: two empty strings are also equal. + expect(issue!.message, key).toBe(describeOf(StaticTableColumnSchema, key)); + + // Clause ②: the ACCEPT SET is untouched. Same refusal, same address, + // same issue code as the bare `z.never()` spelling reported — only the + // message moved. A `refine`-based helper would have reported `custom` + // here, which is a contract change wearing a message change's clothes. + expect(issue!.code, key).toBe('invalid_type'); + expect(issue!.path, key).toEqual([key]); + } + }); + + it('`align` answers with the full remediation string the card measured', () => { + // One member pinned as a LITERAL, so the derived assertions above cannot + // all drift together. This is the exact string objectui#6105 measured as + // unreachable, and the one an author writing `align: 'right'` now reads. + const result = StaticTableColumnSchema.safeParse({ + header: 'Amount', + accessorKey: 'amount', + align: 'right', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe( + 'RETIRED (objectui#5474) — never read by the static table; use data-table, ' + + 'or a cellClassName like text-right', + ); + } + }); + + it('SCOPE BOUNDARY — the later tombstones still emit zod\'s generic message', () => { + // objectui#6105 was scoped to the nine #5474 keys, deliberately. These + // seven — the five rich-shape arrivals tombstoned here under the lockstep + // rule (#6424 / #6425) and the static table's own `hoverable` / `striped` + // pair — were left on the bare spelling. Pinned so the remaining half is a + // recorded decision with a red test behind it rather than an oversight; + // the follow-up that converts them flips this expectation deliberately. + for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency'] as const) { + const result = StaticTableColumnSchema.safeParse({ + header: 'Amount', + accessorKey: 'amount', + [key]: RETIRED_COLUMN_KEYS[key], + }); + expect(result.success, key).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message, key).toContain(ZOD_GENERIC_NEVER); + } + } + for (const key of ['hoverable', 'striped'] as const) { + const result = TableZod.safeParse({ ...STATIC_TABLE, [key]: true }); + expect(result.success, key).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message, key).toContain(ZOD_GENERIC_NEVER); + } + } + }); +}); + /* ── 2. the rich surface is untouched ────────────────────────────────────── */ describe('rich `TableColumn` — NOT narrowed by the split (ruling scope, objectui#5474)', () => { diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index b396ab2aa4..74c7bfa60f 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { ChartTypeSchema as SpecChartTypeSchema } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; +import { retirementTombstone } from './tombstone.zod.js'; import { TABLE_COLUMN_TYPES } from '../data-display.js'; /** @@ -164,12 +165,28 @@ export const TableColumnSchema = z.object({ * Option C: split the types). `TableColumnSchema` above remains the rich * shared shape `data-table` honours and is deliberately NOT narrowed. * - * The `z.never().optional()` members are ADR-0049 retirement tombstones (the - * convention `crud.zod.ts` `confirm` set): an authored value is REFUSED at - * parse time with the key named in the error path, instead of being silently - * stripped the way an undeclared key would be. Loud refusal is the ruled - * outcome — these keys were accepted-and-inert for as long as the static - * table shared the rich column type. + * The `never`-typed members are ADR-0049 retirement tombstones (the convention + * `crud.zod.ts` `confirm` set): an authored value is REFUSED at parse time with + * the key named in the error path, instead of being silently stripped the way + * an undeclared key would be. Loud refusal is the ruled outcome — these keys + * were accepted-and-inert for as long as the static table shared the rich + * column type. + * + * The nine keys the #5474 split retired carry that refusal through + * `retirementTombstone()` (`./tombstone.zod.ts`), which writes the guidance + * string ONCE into both author-facing channels — `.describe()` for generated + * JSON-Schema and docs, and the parse-time issue message for the author who + * trips it. Until objectui#6105 the string reached only the first: the runtime + * message was zod's generic `"Invalid input: expected never, received string"`, + * which names the key but not the remedy, so the loud refusal arrived without + * the half that teaches. The accept set is untouched by that conversion — same + * `success`, same issue `path`, same issue `code` (`invalid_type`); only the + * message differs. + * + * The five later arrivals below (`headerIcon` / `fitContent`, objectui#6424; + * `format` / `options` / `currency`, objectui#6425) still carry the bare + * spelling and still emit zod's generic message — deliberately out of #6105's + * scope, not an oversight. */ export const StaticTableColumnSchema = z.object({ header: z.string().describe('Column header text'), @@ -177,15 +194,15 @@ export const StaticTableColumnSchema = z.object({ className: z.string().optional().describe('Column class name'), cellClassName: z.string().optional().describe('Cell class name'), width: z.union([z.string(), z.number()]).optional().describe('Column width'), - minWidth: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - align: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table, or a cellClassName like text-right'), - fixed: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - type: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - sortable: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - filterable: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - resizable: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - editable: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), - cell: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'), + minWidth: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + align: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table, or a cellClassName like text-right'), + fixed: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + type: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + sortable: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + filterable: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + resizable: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + editable: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), + cell: retirementTombstone('RETIRED (objectui#5474) — never read by the static table; use data-table'), headerIcon: z.never().optional().describe('NOT on the static table surface (objectui#6424) — declared on the rich TableColumn only; use data-table'), fitContent: z.never().optional().describe('NOT on the static table surface (objectui#6424) — declared on the rich TableColumn only; use data-table'), format: z.never().optional().describe('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'), diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts new file mode 100644 index 0000000000..d9be0c4558 --- /dev/null +++ b/packages/types/src/zod/tombstone.zod.ts @@ -0,0 +1,65 @@ +/** + * 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. + */ + +/** + * @object-ui/types/zod - ADR-0049 retirement tombstone helper + * + * @module zod/tombstone + * @packageDocumentation + */ + +import { z } from 'zod'; + +/** + * Declare an ADR-0049 RETIREMENT TOMBSTONE: a key that stays declared but is + * unwritable, so an authored value is REFUSED loudly instead of being silently + * stripped the way an undeclared key would be (the convention `crud.zod.ts` + * `confirm` established; objectui#5474's ruling records loud refusal as the + * intended outcome). + * + * `guidance` is written ONCE and carried into BOTH author-facing channels: + * + * 1. `.describe()` — schema METADATA, which feeds generated JSON-Schema and + * the docs surface. This is where the text already lived. + * 2. `z.never({ error })` — the parse-time ISSUE MESSAGE, which is what an + * author who trips the tombstone actually reads. Without it zod emits its + * own generic `"Invalid input: expected never, received string"`, which + * names WHICH key is wrong (via the issue path) but says nothing about why + * it was retired or what to write instead — so half of the loud refusal's + * payload was being dropped (objectui#6105). `DashboardConfigSchema.aria` + * (`complex.zod.ts`, objectui#5852) landed the spelling by hand first; + * this is that spelling as one shared mechanism. + * + * ONE argument feeding TWO channels is the point: the message an author reads + * and the text generated docs publish cannot drift apart, because there is only + * one string. + * + * ## What this deliberately does NOT change: the accept set + * + * `z.never({ error })` customises the MESSAGE only. The issue `code` stays + * `invalid_type` and the issue `path` still names the key — exactly what a bare + * `z.never()` reports — and `z.input` still types the key `never`, so `tsc` + * refuses it at the authoring site before anything runs. Nothing that parsed + * green parses red, or the reverse. Pinned member-by-member against the + * pre-change readings in `../__tests__/static-table-narrow-surface.test.ts`. + * + * ## Not `@objectstack/spec`'s `retiredKey` + * + * The spec has a same-shaped helper (`shared/retired-key.ts`) for keys removed + * from the SPEC, and it deliberately prefixes its describe text with + * `[REMOVED] `. This one must not: these describe strings are already-published + * metadata and stay byte-identical through this conversion. Same shape, + * different describe contract — do not swap one for the other. + * + * Internal to this package's zod modules — deliberately NOT re-exported from + * `index.zod.ts`, since nothing outside `@object-ui/types` declares these + * schemas. + */ +export function retirementTombstone(guidance: string) { + return z.never({ error: guidance }).optional().describe(guidance); +}