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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
29 changes: 29 additions & 0 deletions .changeset/6105-tombstone-refusal-message.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)', () => {
Expand Down
47 changes: 32 additions & 15 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';

/**
Expand DownExpand Up@@ -164,28 +165,44 @@ 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'),
accessorKey: z.string().describe('Data accessor key'),
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'),
Expand Down
65 changes: 65 additions & 0 deletions packages/types/src/zod/tombstone.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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);
}
Loading