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
51 changes: 51 additions & 0 deletions .changeset/6121-report-authoring-face.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
'@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<Record<ReportExportFormat, ReportExportConfig>>` 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.

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,
produced a fork the ruling did not cover. It is escalated on objectui#6121.
22 changes: 15 additions & 7 deletions content/docs/core/report-schema.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,7 +35,7 @@ ReportComponentSchema provides:

## Basic Usage

```plaintext
```ts
import type { ReportComponentSchema } from '@object-ui/types';

const salesReport: ReportComponentSchema = {
Expand DownExpand Up@@ -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]
}
]
}
Expand DownExpand Up@@ -402,7 +402,8 @@ const builder: ReportBuilderSchema = {
type: 'report-builder',

report: {
// Initial report configuration
type: 'report',
title: 'Untitled Report'
},

dataSources: [
Expand All@@ -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<Record<string, unknown>>;

const viewer: ReportViewerSchema = {
type: 'report-viewer',
Expand All@@ -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) {
Expand Down
161 changes: 161 additions & 0 deletions packages/types/src/__tests__/report-schema-authoring-face.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<Record>`.**
* A total `Record<ReportExportFormat, ReportExportConfig>` 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<ReportExportFormat, ReportExportConfig>':
* 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<Eq<…>>` 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<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Assert<T extends true> = T;

describe('objectui#6121 — ReportComponentSchema.exportConfigs is partial', () => {
// 1a. THE PIN. Exact identity against `Partial<Record<…>>`. Narrowing back to
// the total `Record<ReportExportFormat, ReportExportConfig>` fails this line,
// and so does widening it to an untyped bag.
type ExportConfigs = NonNullable<ReportComponentSchema['exportConfigs']>;
type _ExportConfigsStayPartial = Assert<
Eq<ExportConfigs, Partial<Record<ReportExportFormat, ReportExportConfig>>>
>;

// 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<SeriesType, 'bar' | 'line' | 'area' | undefined>
>;

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();
});
});
23 changes: 23 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*/
Expand Down
21 changes: 19 additions & 2 deletions packages/types/src/reports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -410,9 +410,26 @@ export interface ReportComponentSchema extends BaseSchema {
defaultExportFormat?: ReportExportFormat;

/**
* Export configurations
* Per-format export configuration, keyed by {@link ReportExportFormat}.
*
* `Partial<Record<…>>`, 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<ReportExportFormat, ReportExportConfig>;
exportConfigs?: Partial<Record<ReportExportFormat, ReportExportConfig>>;

/**
* Show export buttons
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'),
});

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-doc-fence-languages.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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],
Expand Down
Loading