diff --git a/.changeset/6121-retire-report-data-source.md b/.changeset/6121-retire-report-data-source.md new file mode 100644 index 000000000..18a52fabd --- /dev/null +++ b/.changeset/6121-retire-report-data-source.md @@ -0,0 +1,48 @@ +--- +'@object-ui/types': minor +--- + +The two report data-source keys are retired on both faces (objectui#6121, +maintainer ruling of 2026-08-30, decision batch #8 — option A's retirement half; +ADR-0049 enforce-or-remove). + +**The accept set of a published validator moves** (`@object-ui/types/zod`): + +- `ReportComponentSchema.dataSource` was `z.any().optional()`, so any JSON value + parsed green and was then read by nobody. +- `ReportBuilderSchema.dataSources` was `z.array(z.any()).optional()`, on a node + type no renderer is registered for at all. + +Both now carry `retirementTombstone(...)`: an authored value is refused at the +key's own path with `code: 'invalid_type'` and a message that names the key, says +why it is retired and points at the spelling that runs. Nothing that used to be +refused parses green. + +**The TypeScript face** — both keys become `?: never` rather than being deleted, +so an author who still writes one gets a `tsc` error at the authoring site +instead of a silently stripped key. They were annotated `DataSource` / +`DataSource[]`, the runtime ADAPTER interface (`find(resource, params)`), which +no JSON document can author; that mis-annotation is the defect objectui#6121 was +filed for, since every example on `content/docs/core/report-schema.mdx` authored +a config object against it. + +**Why this is a retirement and not a rename.** No read site consumed either key: +`@object-ui/plugin-report`'s `ReportRenderer` takes its adapter as a React prop +or from `SchemaRendererContext`, never off `schema.dataSource`, and the live +9.0 path binds a semantic-layer `dataset` (ADR-0021). Authored occurrences +measured zero in this repo and in the sibling `objectstack` checkout, whose +report metadata binds `dataset` throughout — the ruling's own deprecation-window +exit criterion. A stored document that still carries the key now fails loudly at +`safeParse` instead of being accepted and ignored; drop the key, and bind the +report through `dataset`. + +The replacement binding key the ruling names (`data?: ViewData`) is deliberately +NOT declared here, and is escalated on objectui#6121: `data` is already a live +key on `ReportComponentSchema` — the report ROW array, read by +`LegacyReportRenderer` as `data.length` / `data.map` — so declaring the binding +under that name would put two authoring contracts on one key inside one +renderer. + +Pinned in `packages/types/src/__tests__/report-schema-authoring-face.test.ts`: +the `never` twins, the named refusals with their issue envelope, the `.describe()` +metadata channel, and controls that a report without the key still parses. diff --git a/content/docs/core/report-schema.mdx b/content/docs/core/report-schema.mdx index 8308cd7b8..f2548e359 100644 --- a/content/docs/core/report-schema.mdx +++ b/content/docs/core/report-schema.mdx @@ -85,7 +85,18 @@ complete list. | `type` | `'report'` | Component type identifier (required) | | `title` | `string` | Report title | | `description` | `string` | Report description | -| `dataSource` | `DataSource` | Data source configuration | + +> **Retired (objectui#6121):** `ReportComponentSchema.dataSource` and +> `ReportBuilderSchema.dataSources` used to be documented and declared here. +> Both were annotated with `DataSource`, the runtime **adapter** interface +> (`find(resource, params)`), which no JSON document can author — and no +> renderer ever read either key off a schema: the report renderers take their +> adapter as a React prop or from the renderer context. Both keys are now +> `never` on the TypeScript face and are refused **by name** by the published +> validator, so an authored value fails loudly instead of being accepted and +> ignored. A report binds its data through the semantic-layer `dataset` form +> (ADR-0021); a legacy presentation report receives already-fetched rows under +> `data`. ### Report Fields @@ -227,15 +238,6 @@ const comprehensiveReport: ReportComponentSchema = { title: 'Quarterly Sales Analysis', description: 'Comprehensive sales performance analysis by region and product', - // Data source - dataSource: { - provider: 'api', - read: { - url: '/api/sales', - method: 'GET' - } - }, - // Report fields fields: [ { @@ -406,11 +408,6 @@ const builder: ReportBuilderSchema = { title: 'Untitled Report' }, - dataSources: [ - { provider: 'api', read: { url: '/api/sales' } }, - { provider: 'api', read: { url: '/api/customers' } } - ], - availableFields: [ { name: 'revenue', label: 'Revenue', type: 'number' }, { name: 'units', label: 'Units Sold', type: 'number' } diff --git a/packages/types/src/__tests__/report-schema-authoring-face.test.ts b/packages/types/src/__tests__/report-schema-authoring-face.test.ts index 7cca6fca3..dc1ff3853 100644 --- a/packages/types/src/__tests__/report-schema-authoring-face.test.ts +++ b/packages/types/src/__tests__/report-schema-authoring-face.test.ts @@ -63,17 +63,33 @@ * 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. + * + * ## 3. The RETIREMENT this card's own ruling ordered (maintainer, 2026-08-30) + * + * The third section pins the opposite direction: `ReportComponentSchema.dataSource` + * and `ReportBuilderSchema.dataSources` are RETIRED. Both were annotated with the + * runtime `DataSource` ADAPTER — a shape no JSON document can author — and no read + * site ever consumed either key. A retirement needs its own pin for the mirror + * reason a widening does: `?: never` compiles for every existing caller (nobody + * wrote the key), so nothing would fail if a later edit restored the adapter + * annotation or relaxed the mirror back to `z.any()`. Both halves are pinned — + * the `never` TypeScript twin AND the mirror's named refusal — because either one + * alone leaves `declared !== enforced`, which is the defect ADR-0049 names. */ import { describe, it, expect } from 'vitest'; import type { + ReportBuilderSchema, 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'; +import { + ReportBuilderSchema as ReportBuilderZodSchema, + 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 @@ -162,3 +178,98 @@ describe('objectui#6121 — ChartDataSeries declares the per-series family overr expect(ChartDataSeriesSchema.parse(plain).type).toBeUndefined(); }); }); + +describe('objectui#6121 — the two report data-source keys are retired on both faces', () => { + // 3a. THE TYPE PIN. `?: never` resolves the member type to `undefined`, so + // this line fails if either key is restored to `DataSource` / `DataSource[]` + // — or to any other value type, including the `ViewData` binding whose key + // name is still an open question on this card. + type RetiredDataSource = ReportComponentSchema['dataSource']; + type RetiredDataSources = ReportBuilderSchema['dataSources']; + type _DataSourceStaysRetired = Assert>; + type _DataSourcesStayRetired = Assert>; + + it('refuses an authored `dataSource` on both faces, by name', () => { + const authored = { + type: 'report' as const, + title: 'Quarterly Sales Analysis', + // The exact face `content/docs/core/report-schema.mdx` used to teach. + dataSource: { provider: 'api', read: { url: '/api/sales', method: 'GET' } }, + }; + + // @ts-expect-error `dataSource` is retired — `?: never` admits no value + const typed: ReportComponentSchema = authored; + expect(typed).toBeTruthy(); + + const result = ReportComponentZodSchema.safeParse(authored); + expect(result.success).toBe(false); + // The ENVELOPE, not the fact that something failed: one issue, at this + // key's own path, reported as `invalid_type` (what `z.never()` emits) — + // and carrying the tombstone's guidance rather than zod's generic text, + // which is the half `retirementTombstone` exists for. + const issues = result.success ? [] : result.error.issues; + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'dataSource']]); + expect(issues[0]?.message).toContain('RETIRED (objectui#6121, ADR-0049)'); + }); + + it('refuses an authored `dataSources` on the builder, by name', () => { + const authored = { + type: 'report-builder' as const, + dataSources: [{ provider: 'api', read: { url: '/api/sales' } }], + }; + + // @ts-expect-error `dataSources` is retired — `?: never` admits no value + const typed: ReportBuilderSchema = authored; + expect(typed).toBeTruthy(); + + const result = ReportBuilderZodSchema.safeParse(authored); + expect(result.success).toBe(false); + const issues = result.success ? [] : result.error.issues; + expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'dataSources']]); + expect(issues[0]?.message).toContain('RETIRED (objectui#6121, ADR-0049)'); + }); + + // 3b. CONTROLS, in the same run. Two zeros above need two things that fire: + // without these, a mirror that refused EVERYTHING would read as a pass, and + // so would a `.safeParse` that had stopped being called at all. + it('the same report without the retired key still parses, and the row array is untouched', () => { + const report: ReportComponentSchema = { + type: 'report', + title: 'Quarterly Sales Analysis', + // `data` is the report ROW array — a live key with a live read + // (`LegacyReportRenderer` reads `data.length` / `data.map`). It is NOT + // the retired binding, and this control is what keeps the retirement + // above from reading as "reports refuse data". + data: [{ region: 'EMEA', revenue: 1 }], + }; + const result = ReportComponentZodSchema.safeParse(report); + expect(result.success).toBe(true); + expect(result.success && result.data.data).toHaveLength(1); + + const builder: ReportBuilderSchema = { type: 'report-builder', showPreview: true }; + expect(ReportBuilderZodSchema.safeParse(builder).success).toBe(true); + }); + + // 3c. The guidance reaches the OTHER author-facing channel too — the + // `.describe()` metadata that feeds generated JSON Schema and the docs + // surface. One string, two channels, so they cannot drift apart. + it('publishes the retirement guidance as schema metadata', () => { + const shapeOf = (schema: { shape: Record }) => schema.shape; + const componentDescribe = shapeOf( + ReportComponentZodSchema as unknown as { shape: Record }, + ).dataSource?.description; + const builderDescribe = shapeOf( + ReportBuilderZodSchema as unknown as { shape: Record }, + ).dataSources?.description; + + expect(componentDescribe).toContain('RETIRED (objectui#6121, ADR-0049)'); + expect(builderDescribe).toContain('RETIRED (objectui#6121, ADR-0049)'); + // Control for the reader itself: a NON-retired member's description is + // still its own noun, so the two hits above are not "every key says + // RETIRED". + expect( + shapeOf(ReportComponentZodSchema as unknown as { shape: Record }) + .title?.description, + ).toBe('Report title'); + }); +}); diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index e2113e083..b11d3bed6 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -34,7 +34,6 @@ import type { z } from 'zod'; import type { ReportType as SpecReportType } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; import type { ChartSchema } from './data-display.js'; -import type { DataSource } from './data.js'; /** * Report Export Format @@ -375,9 +374,37 @@ export interface ReportComponentSchema extends BaseSchema { reportType?: ReportType; /** - * Data source configuration + * Data source configuration — RETIRED (objectui#6121, maintainer ruling of + * 2026-08-30, decision batch #8; ADR-0049 enforce-or-remove). + * + * The key was annotated `DataSource`, the RUNTIME ADAPTER interface declared + * in `./data.ts` (`find(resource, params)`, `searchAll?()`, and friends). No + * JSON document can author that shape, and nothing ever read the key off a + * report schema: `@object-ui/plugin-report`'s `ReportRenderer` takes its + * adapter as a React prop or off `SchemaRendererContext`, never off + * `schema.dataSource`, and the live 9.0 path binds a semantic-layer + * `dataset` instead (ADR-0021). Measured zero authored occurrences in this + * repo and in the sibling `objectstack` checkout, whose authored reports all + * bind `dataset` — that measurement is the ruling's own deprecation-window + * exit criterion. + * + * `?: never` rather than deleted, so an author who still writes the key gets + * a `tsc` error at the authoring site and a NAMED refusal from the zod twin + * (`retirementTombstone` in `./zod/reports.zod.ts`) instead of a silently + * stripped key — the disposition objectui#7344 landed for `onSave` / + * `onCancel` on {@link ReportBuilderSchema}. + * + * ⚠️ The REPLACEMENT binding key the ruling names — `data?: ViewData` — is + * deliberately NOT declared here. `data` is already taken on this interface + * by the report ROW array below, which `LegacyReportRenderer` reads + * (`data.length`, `data.map`, and as the chart's rows); declaring the + * binding under that same name would put two authoring contracts on one key + * inside one renderer, which is the objectstack#5576 collision this card's + * own ruling rejected option D for. Escalated on objectui#6121. + * + * @deprecated Retired — no read site ever consumed this key. */ - dataSource?: DataSource; + dataSource?: never; /** * Report fields @@ -494,9 +521,21 @@ export interface ReportBuilderSchema extends BaseSchema { report?: ReportComponentSchema; /** - * Available data sources + * Available data sources — RETIRED (objectui#6121, maintainer ruling of + * 2026-08-30, decision batch #8; ADR-0049 enforce-or-remove). + * + * Same reading as {@link ReportComponentSchema.dataSource}, one degree + * further from a reader: no renderer is registered for `report-builder` at + * all — measured, zero `ComponentRegistry.register('report-builder', …)` + * sites, with the bare `'report'` registration in `@object-ui/plugin-report` + * as the positive control that makes that zero a reading. It is the same + * measurement that retired `onSave` / `onCancel` below (objectui#7344), and + * the declared element type was an array of the runtime `DataSource` + * ADAPTER, which JSON cannot author. + * + * @deprecated Retired — no read site ever consumed this key. */ - dataSources?: DataSource[]; + dataSources?: never; /** * Available fields diff --git a/packages/types/src/zod/reports.zod.ts b/packages/types/src/zod/reports.zod.ts index c31fb670f..a25453061 100644 --- a/packages/types/src/zod/reports.zod.ts +++ b/packages/types/src/zod/reports.zod.ts @@ -19,7 +19,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { ChartSchema } from './data-display.zod.js'; -import { handlerKeyRefusal } from './tombstone.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; /** * Report Export Format Schema @@ -143,7 +143,17 @@ export const ReportComponentSchema = BaseSchema.extend({ type: z.literal('report'), title: z.string().optional().describe('Report title'), description: z.string().optional().describe('Report description'), - dataSource: z.any().optional().describe('Data source configuration'), + // RETIRED (objectui#6121, maintainer ruling of 2026-08-30, decision batch #8; + // ADR-0049 enforce-or-remove). The TS twin is `dataSource?: never`; the key + // stays DECLARED so an authored value is refused BY NAME instead of being + // waved through by `z.any()` and then read by nobody. + dataSource: retirementTombstone( + 'Data source configuration — RETIRED (objectui#6121, ADR-0049). The key was declared as the ' + + 'runtime `DataSource` ADAPTER (`find(resource, params)`), which JSON has no value for, and ' + + 'no renderer ever read it off a report schema: the report renderers take their adapter as a ' + + 'React prop or from `SchemaRendererContext`. Bind a report through the semantic-layer ' + + '`dataset` form (ADR-0021); a legacy presentation report receives its rows under `data`.', + ), fields: z.array(ReportFieldSchema).optional().describe('Report fields'), filters: z.array(ReportFilterSchema).optional().describe('Report filters'), groupBy: z.array(ReportGroupBySchema).optional().describe('Group by configuration'), @@ -165,7 +175,16 @@ export const ReportComponentSchema = BaseSchema.extend({ export const ReportBuilderSchema = BaseSchema.extend({ type: z.literal('report-builder'), report: ReportComponentSchema.optional().describe('Initial report configuration'), - dataSources: z.array(z.any()).optional().describe('Available data sources'), + // RETIRED with `ReportComponentSchema.dataSource` above (objectui#6121), one + // degree further from a reader: no renderer is registered for + // `report-builder`, the same measurement that retired the two handler keys + // below (objectui#7344). + dataSources: retirementTombstone( + 'Available data sources — RETIRED (objectui#6121, ADR-0049). No renderer is registered for ' + + '`report-builder`, so nothing could ever read this key, and it was declared as an array of ' + + 'the runtime `DataSource` ADAPTER, which JSON has no value for. Bind a report through the ' + + 'semantic-layer `dataset` form (ADR-0021).', + ), availableFields: z.array(ReportFieldSchema).optional().describe('Available fields'), showPreview: z.boolean().optional().describe('Show preview'), // RETIRED (objectui#7344, the objectui#6182 ruling in the objectui#6124 shape):