From d01c14c1032fc2d89cfe4ee75c07b6fcceef8045 Mon Sep 17 00:00:00 2001 From: os-sam Date: Sun, 30 Aug 2026 12:10:38 +0000 Subject: [PATCH 1/3] fix(types): the report authoring face declares what its examples author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReportComponentSchema.exportConfigs` was a TOTAL `Record` over `ReportExportFormat`, so configuring one export format was a type error unless the author declared all five. Its own runtime twin was never total (`z.record(z.string(), ReportExportConfigSchema)`), so the published TS declaration was stricter than the validator that judges authored JSON. `ChartDataSeries` gains the per-series family override `type`, which `normalizeChartSchema` in @object-ui/plugin-charts already reads (`str(raw.chartType) ?? str(raw.type)`) and the documentation already authors. The union is the three families that read honours, not the spec's wider `ChartType` — a wider union would advertise an override the normalizer drops in silence. Both relaxations are pinned, in both directions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../report-schema-authoring-face.test.ts | 161 ++++++++++++++++++ packages/types/src/data-display.ts | 23 +++ packages/types/src/reports.ts | 21 ++- packages/types/src/zod/data-display.zod.ts | 4 + 4 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/report-schema-authoring-face.test.ts diff --git a/packages/types/src/__tests__/report-schema-authoring-face.test.ts b/packages/types/src/__tests__/report-schema-authoring-face.test.ts new file mode 100644 index 0000000000..3ce6d25df7 --- /dev/null +++ b/packages/types/src/__tests__/report-schema-authoring-face.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The report authoring face keeps the two shapes objectui#6121 relaxed — pinned + * so a later narrowing is a red test rather than a silent re-break. + * + * ## Why a relaxation needs a pin at all + * + * Both changes below WIDEN a published type: they make authored JSON that used + * to be a type error legal. A widening has no natural guard — every existing + * caller still compiles, every test still passes, and nothing anywhere fails if + * someone later "tightens" it back. A relaxation with no pin is indistinguishable + * from an accident, in both directions and at any later date. These are the pins. + * + * ## What was relaxed, and the measurement behind each + * + * **1. `ReportComponentSchema.exportConfigs` — total `Record` -> `Partial`.** + * A total `Record` forced an author + * configuring ONE format to declare all five. Measured on + * `content/docs/core/report-schema.mdx`, whose three-format example failed: + * + * TS2739: Type '{ pdf: …; excel: …; csv: … }' is missing the following + * properties from type 'Record': + * html, json + * + * The runtime twin was NEVER total — `../zod/reports.zod.ts` declares + * `exportConfigs: z.record(z.string(), ReportExportConfigSchema)`, whose keys are + * all optional. So the TS declaration was stricter than the validator that + * actually judges authored JSON, and this makes them agree in the direction the + * validator already took. That asymmetry is itself pinned below (section 1c): + * if a later change makes the MIRROR total, the two drift apart again the other + * way and this file says so. + * + * **2. `ChartDataSeries.type` — declared, because the renderer already reads it.** + * The chart example on the same page authors `type: 'line'` on a series, which + * was `TS2353: … 'type' does not exist in type 'ChartDataSeries'`. It is not a + * documentation slip: `normalizeChartSchema`'s `normalizeSeries` + * (`@object-ui/plugin-charts`) reads exactly that key — + * + * const family = str(raw.chartType) ?? str(raw.type); + * if (family === 'bar' || family === 'line' || family === 'area') … + * + * — so `type` is the AUTHOR spelling of the per-series family override that + * `chartType` carries internally. The union pinned here is those three families + * and no more: a wider union would advertise an override the normalizer drops in + * silence (declared-but-unenforced, ADR-0049's shape). + * + * ⚠️ This is NOT `@objectstack/spec`'s `ChartSeries`, whose `type` is the full + * `ChartType`. The two are deliberately separate shapes (objectstack#4115) — see + * the `ChartDataSeries` header in `../data-display.ts`. Narrowing THIS union to + * match the spec's would be the same mistake in reverse. + * + * ## Which instrument checks which assertion (stated, because they differ) + * + * The `Assert>` lines are TYPE-level and are judged by + * `pnpm --filter @object-ui/types type-check`, whose third leg is + * `tsc -p tsconfig.test.json` — the project that exists precisely because + * `tsconfig.json` excludes every `.test.ts` file. ⛔ They are NOT judged by `vitest`, + * which strips types. That distinction is this package's own scar tissue: + * `spec-derived-unions.test.ts` once built its whole contract on `satisfies` + * checks that no `tsc` invocation ever read (objectstack#4074). + * + * The `expect(…)` lines below are RUNTIME and are judged by vitest. Every + * relaxation therefore carries at least one assertion of each kind, so neither + * instrument going missing can make this file vacuous on its own. + */ + +import { describe, it, expect } from 'vitest'; +import type { + ReportComponentSchema, + ReportExportConfig, + ReportExportFormat, +} from '../reports.js'; +import type { ChartDataSeries } from '../data-display.js'; +import { ChartDataSeriesSchema } from '../zod/data-display.zod.js'; +import { ReportComponentSchema as ReportComponentZodSchema } from '../zod/reports.zod.js'; + +/** `true` only when the two types are mutually assignable AND identical. */ +type Eq = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Assert = T; + +describe('objectui#6121 — ReportComponentSchema.exportConfigs is partial', () => { + // 1a. THE PIN. Exact identity against `Partial>`. Narrowing back to + // the total `Record` fails this line, + // and so does widening it to an untyped bag. + type ExportConfigs = NonNullable; + type _ExportConfigsStayPartial = Assert< + Eq>> + >; + + // 1b. The capability the relaxation exists for: ONE format, annotated at the + // declaration so excess/missing-property checking is really engaged. + it('accepts a single-format configuration', () => { + const oneFormat: ReportComponentSchema = { + type: 'report', + exportConfigs: { + csv: { format: 'csv', filename: 'sales.csv', includeHeaders: true }, + }, + }; + expect(Object.keys(oneFormat.exportConfigs ?? {})).toEqual(['csv']); + + // …and the same literal through the published validator, which is the half a + // JSON author actually meets. + const parsed = ReportComponentZodSchema.safeParse(oneFormat); + expect(parsed.success).toBe(true); + }); + + // 1c. The asymmetry that made the total Record wrong: the mirror accepts a + // one-key map. If a later change makes the mirror total, this fails. + it('the published validator accepts a partial export map', () => { + const result = ReportComponentZodSchema.safeParse({ + type: 'report', + exportConfigs: { pdf: { format: 'pdf' } }, + }); + expect(result.success).toBe(true); + }); + + // 1d. Still keyed by the format union — the relaxation must not have become + // "any string key". An unknown format stays a type error. + it('rejects an unknown export format key', () => { + // @ts-expect-error 'xml' is not a ReportExportFormat + const bad: ReportComponentSchema = { type: 'report', exportConfigs: { xml: { format: 'csv' } } }; + expect(bad).toBeTruthy(); + }); +}); + +describe('objectui#6121 — ChartDataSeries declares the per-series family override', () => { + // 2a. THE PIN. Exactly the three families `normalizeChartSchema` honours. + // Removing the key, or widening it to the spec's full `ChartType`, fails here. + type SeriesType = ChartDataSeries['type']; + type _SeriesTypeStaysThreeFamilies = Assert< + Eq + >; + + it('accepts the series shape the documentation authors', () => { + const series: ChartDataSeries = { + name: 'Revenue', + type: 'line', + data: [120000, 145000, 132000], + }; + expect(series.type).toBe('line'); + // The zod twin moves in lockstep — an unmirrored key is what + // `zod-mirror-parity.test.ts` fails on. + expect(ChartDataSeriesSchema.parse(series).type).toBe('line'); + }); + + it('rejects a family the normalizer would silently drop', () => { + // @ts-expect-error 'pie' is not a per-series override the renderer performs + const bad: ChartDataSeries = { name: 'Revenue', type: 'pie', data: [1] }; + expect(bad).toBeTruthy(); + expect(ChartDataSeriesSchema.safeParse({ name: 'Revenue', type: 'pie', data: [1] }).success) + .toBe(false); + }); + + it('leaves the override optional — a plain inline series still parses', () => { + const plain: ChartDataSeries = { name: 'Revenue', data: [1, 2, 3] }; + expect(ChartDataSeriesSchema.parse(plain).type).toBeUndefined(); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 90cafd8334..b76c8302f7 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1122,6 +1122,29 @@ export interface ChartDataSeries { * Series data points */ data: number[]; + /** + * Per-series chart family override, for a combo chart: this series draws as a + * line (or bar, or area) on a chart whose own `chartType` is something else. + * + * Declared because the renderer already READS it and the documentation already + * authored it (objectui#6121, maintainer ruling 2026-08-25). The read is + * `normalizeChartSchema`'s `normalizeSeries` in `@object-ui/plugin-charts`: + * + * const family = str(raw.chartType) ?? str(raw.type); + * if (family === 'bar' || family === 'line' || family === 'area') … + * + * so `type` is the AUTHOR spelling of the same override `chartType` carries + * internally, and it reaches `NormalizedSeries.chartType` either way. + * + * ⚠️ The union is the three families that read does honour — NOT the full + * {@link ChartType}. A `type: 'pie'` on a series is dropped in silence by the + * normalizer, so declaring the wider union would advertise a per-series + * override that nothing performs. `@objectstack/spec`'s own `ChartSeries.type` + * is the wider `ChartType`; this is the objectui inline-data node's series, a + * deliberately separate shape (objectstack#4115 — see this interface's header), + * and it declares what its own renderer enforces. + */ + type?: 'bar' | 'line' | 'area'; /** * Series color */ diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index b0c2a5e5a3..06987d5870 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -410,9 +410,26 @@ export interface ReportComponentSchema extends BaseSchema { defaultExportFormat?: ReportExportFormat; /** - * Export configurations + * Per-format export configuration, keyed by {@link ReportExportFormat}. + * + * `Partial>`, not a total `Record` (objectui#6121, maintainer ruling + * 2026-08-25): a total `Record` made configuring ONE format an error unless the + * author declared all five (`pdf`, `excel`, `csv`, `html`, `json`) — the + * documented three-format example on `content/docs/core/report-schema.mdx` + * failed with `TS2739 … missing the following properties …: html, json`. + * + * The runtime twin was never total: `ReportComponentSchema.exportConfigs` in + * `./zod/reports.zod.ts` is `z.record(z.string(), ReportExportConfigSchema)`, + * whose keys are all optional. So the TS declaration was stricter than the + * validator that actually judges authored JSON — a format the type demanded + * and the parser did not. This makes the two agree, in the direction the + * validator already took. + * + * A format absent from this map exports with the renderer's defaults; it is + * not "unsupported". Widening pinned by + * `__tests__/report-schema-authoring-face.test.ts`. */ - exportConfigs?: Record; + exportConfigs?: Partial>; /** * Show export buttons diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index c765a7ae7a..3da1839123 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -313,6 +313,10 @@ export const ChartTypeSchema = SpecChartTypeSchema; export const ChartDataSeriesSchema = z.object({ name: z.string().describe('Series name'), data: z.array(z.number()).describe('Series data points'), + // Mirrors `ChartDataSeries.type` (objectui#6121). The three families are the + // ones `normalizeChartSchema` actually honours as a per-series override; see + // the TS declaration for the read this narrowness is taken from. + type: z.enum(['bar', 'line', 'area']).optional().describe('Per-series chart family override (combo charts)'), color: z.string().optional().describe('Series color'), }); From a037c1206da864bd86ee11c688b5b38d5fb57f8e Mon Sep 17 00:00:00 2001 From: os-sam Date: Sun, 30 Aug 2026 12:27:53 +0000 Subject: [PATCH 2/3] docs(core): report-schema examples match the types, and three blocks join the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart example authored a dataset-bound series (`dataKey`, no `data`) on the inline-data `ChartSchema` node, and a chart-level `data: []` that is not a `ChartSchema` key at all — it survived only because `BaseSchema` carries a string index signature, so excess-property checking never engaged. It is now an inline series: `categories` plus `{ name, type, data }`. The report-builder example's `report: {}` placeholder could never be legal — `ReportComponentSchema.type` is the registry discriminator. It now shows a real minimal report rather than weakening the discriminator to admit `{}`. Three blocks are re-fenced `ts` and are now compiled by check:doc-snippets: Basic Usage, Report Viewer and Runtime Validation. The fence ledger drops 11 -> 8 accordingly. The eight blocks left `plaintext` are held deliberately, not overlooked: two are blocked on the `dataSource` question escalated on #6121, and six declare their OWN interface, which objectui#6138 measured compiles vacuously — those want that card's remedy (annotate against the exported type, after a sealedness control), which is #5867 batch work rather than this card's. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6121-report-authoring-face.md | 31 ++++++++++++++++++++++++ content/docs/core/report-schema.mdx | 22 +++++++++++------ scripts/check-doc-fence-languages.mjs | 2 +- 3 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 .changeset/6121-report-authoring-face.md diff --git a/.changeset/6121-report-authoring-face.md b/.changeset/6121-report-authoring-face.md new file mode 100644 index 0000000000..c653a773c8 --- /dev/null +++ b/.changeset/6121-report-authoring-face.md @@ -0,0 +1,31 @@ +--- +'@object-ui/types': minor +--- + +The report authoring face declares what its own examples author (objectui#6121, +maintainer ruling 2026-08-25, Option A — fix the type producer, not the docs). + +- `ReportComponentSchema.exportConfigs` is now + `Partial>` instead of a TOTAL + `Record`. Configuring ONE export format no longer forces an author to declare + all five (`pdf`, `excel`, `csv`, `html`, `json`). The published runtime twin + was never total — `z.record(z.string(), ReportExportConfigSchema)` in + `@object-ui/types/zod` has all keys optional — so the TS declaration had been + stricter than the validator that actually judges authored JSON. This is a pure + relaxation: every literal that type-checked before still does. + +- `ChartDataSeries` gains the optional per-series family override `type` + (`'bar' | 'line' | 'area'`), with the same key added to its zod twin + `ChartDataSeriesSchema`. The renderer already reads it — + `normalizeChartSchema`'s `normalizeSeries` in `@object-ui/plugin-charts` + resolves the family as `str(raw.chartType) ?? str(raw.type)` — so `type` was + the author spelling of an override the type refused to declare. The union is + the three families that read honours, deliberately NOT the wider `ChartType`: + a wider union would advertise an override the normalizer drops in silence. + +Both relaxations carry pins that fail if either is narrowed back +(`packages/types/src/__tests__/report-schema-authoring-face.test.ts`). + +`ReportComponentSchema.dataSource` is NOT changed here — measuring the authorable +shape against the report runtime's actual read, which the ruling requires, +produced a fork the ruling did not cover. It is escalated on objectui#6121. diff --git a/content/docs/core/report-schema.mdx b/content/docs/core/report-schema.mdx index e9bebdc6f4..8308cd7b80 100644 --- a/content/docs/core/report-schema.mdx +++ b/content/docs/core/report-schema.mdx @@ -35,7 +35,7 @@ ReportComponentSchema provides: ## Basic Usage -```plaintext +```ts import type { ReportComponentSchema } from '@object-ui/types'; const salesReport: ReportComponentSchema = { @@ -316,12 +316,12 @@ const comprehensiveReport: ReportComponentSchema = { chart: { type: 'chart', chartType: 'line', - data: [], + categories: ['January', 'February', 'March'], series: [ { name: 'Revenue', type: 'line', - dataKey: 'revenue' + data: [120000, 145000, 132000] } ] } @@ -402,7 +402,8 @@ const builder: ReportBuilderSchema = { type: 'report-builder', report: { - // Initial report configuration + type: 'report', + title: 'Untitled Report' }, dataSources: [ @@ -425,8 +426,12 @@ const builder: ReportBuilderSchema = { Use `ReportViewerSchema` to display generated reports: -```plaintext -import type { ReportViewerSchema } from '@object-ui/types'; +```ts +import type { ReportComponentSchema, ReportViewerSchema } from '@object-ui/types'; + +// The report defined under "Basic Usage" above, and the rows a run produced. +declare const salesReport: ReportComponentSchema; +declare const reportData: Array>; const viewer: ReportViewerSchema = { type: 'report-viewer', @@ -441,9 +446,12 @@ const viewer: ReportViewerSchema = { ## Runtime Validation -```plaintext +```ts import { ReportComponentSchema } from '@object-ui/types/zod'; +// The report configuration to validate. +declare const myReport: unknown; + const result = ReportComponentSchema.safeParse(myReport); if (result.success) { diff --git a/scripts/check-doc-fence-languages.mjs b/scripts/check-doc-fence-languages.mjs index 264d154bd0..814371cb65 100644 --- a/scripts/check-doc-fence-languages.mjs +++ b/scripts/check-doc-fence-languages.mjs @@ -371,7 +371,7 @@ export const KNOWN_UNHIGHLIGHTED_TS_FENCES = new Map([ ['content/docs/components/overlay/popover.mdx', 1], ['content/docs/components/overlay/sheet.mdx', 1], ['content/docs/components/overlay/tooltip.mdx', 1], - ['content/docs/core/report-schema.mdx', 11], + ['content/docs/core/report-schema.mdx', 8], ['content/docs/plugins/plugin-calendar.mdx', 1], ['content/docs/plugins/plugin-chatbot.mdx', 2], ['content/docs/plugins/plugin-dashboard.mdx', 3], From 17b6a92557533e02b9ef882d2296e5828b37630f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 12:47:21 +0000 Subject: [PATCH 3/3] docs(changeset): declare the ChartDataSeries zod twin's reject direction The changeset framed both type changes as relaxations. The `ChartDataSeries` half has a reject-direction leg it did not declare: `ChartDataSeriesSchema` is a stripping `z.object`, so a stored series carrying a non-family `type` (say `'pie'`, copied from the spec's wider `ChartSeries.type`) parsed before this PR with the key dropped in silence, and fails `safeParse` after it. The schema feeds `ChartSchema.series`, so an external consumer validating stored chart JSON newly gets `invalid_value` at `series.N.type`. The narrowing itself stands - it is forced by the TS union plus zod-mirror-parity, and the pin asserts the rejection openly. Only the declaration was missing. Bump stays `minor`: per `check-changeset-no-major.mjs`'s own header, objectui breaking changes ship as minor with the break spelled out in the changeset body, which this supplies. Changeset prose only - no source, schema or test file is touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6121-report-authoring-face.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.changeset/6121-report-authoring-face.md b/.changeset/6121-report-authoring-face.md index c653a773c8..5168b98592 100644 --- a/.changeset/6121-report-authoring-face.md +++ b/.changeset/6121-report-authoring-face.md @@ -23,8 +23,28 @@ maintainer ruling 2026-08-25, Option A — fix the type producer, not the docs). the three families that read honours, deliberately NOT the wider `ChartType`: a wider union would advertise an override the normalizer drops in silence. -Both relaxations carry pins that fail if either is narrowed back -(`packages/types/src/__tests__/report-schema-authoring-face.test.ts`). + NOT only a relaxation on the runtime side, and this is the half a consumer + needs before taking the bump. `ChartDataSeriesSchema` is a stripping + `z.object`, so a stored series carrying a non-family `type` — `type: 'pie'`, + say, copied from `@objectstack/spec`'s `ChartSeries`, whose `type` IS the + full `ChartType` — used to PARSE, with the unrecognised key dropped in + silence; it now FAILS. `ChartDataSeriesSchema` feeds `ChartSchema.series`, + so a consumer running `safeParse` over stored chart JSON newly gets + `invalid_value` at `series.N.type` where it previously got nothing. (Checked + against the package's own zod 4.4.3, both directions, with `type: 'line'` + and a series carrying no `type` as controls: both still parse.) What to do + about it: the rejected value never had an effect — `normalizeSeries` honours + exactly the three families and drops every other one with no error, no + warning and no output key — so the failure surfaces an override that was + already inert. Drop the `type` from the stored series, or, if the whole + chart really is that family, move it to the chart's own `chartType`, which + still takes the full `ChartType`. The TS side is widening-only; only the + published validator newly rejects. + +Both changes carry pins in +`packages/types/src/__tests__/report-schema-authoring-face.test.ts`: the +widenings fail if either is narrowed back, and the rejection above is pinned +openly as `rejects a family the normalizer would silently drop`. `ReportComponentSchema.dataSource` is NOT changed here — measuring the authorable shape against the report runtime's actual read, which the ruling requires,