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
2 changes: 1 addition & 1 deletion .changeset/19920-exported-types-not-unknown.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand All @@ -18,6 +18,6 @@

The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge.

`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`.
`JoinedReportBlock` is not changed by this change. It stops resolving to `unknown` in its own entry (#19920).

<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->
26 changes: 26 additions & 0 deletions .changeset/19920-exported-types-remainder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/spec': minor
---

fix(spec): `JoinedReportBlock`, a ViewItem's `config`, a flattened overlay's `viewKind` and a flattened list overlay's `type` / `columns` carry the shapes their doors accept (#19920)

Clause-②: yes (narrowing)

**BREAKING for TypeScript code that annotates with `JoinedReportBlock`, `Report`, `ReportParsed`, `ViewItem`, `ViewItemWire`, `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`, or that passes an unchecked value to `defineReport` / `defineViewItem`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema's parse, no value and no existing export changes. Three parsed-state type names are added (below); nothing is removed or renamed.

Four places in the published types were wider than the doors that judge the same bodies, so values those doors refuse type-checked:

- `JoinedReportBlock`: FROM `unknown` TO the input shape of `JoinedReportBlockSchema`. The schema was annotated `z.ZodTypeAny`, which erased its shape; it now carries its inferred type. The same erasure made every `blocks[]` element of `Report` / `ReportParsed` (and so of `defineReport`'s parameter) `unknown`; each is now a block.
- A ViewItem's `config`: FROM `unknown` TO the arm's own config type, a `ListView` config on the `list` arm and a `FormView` config on the `form` arm. This holds on `ViewItem`, `ViewItemWire`, `defineViewItem`'s parameter and return, and the `viewItem` member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The arm builder took `config` as `z.ZodTypeAny`; it is now a generic parameter.
- A flattened overlay member's `viewKind`: FROM `'list' | 'form'` on both members TO `'list'` on the list overlay and `'form'` on the form overlay, the one value each member accepts. A list-shaped body naming `viewKind: 'form'` used to type-check, through the list overlay member, as `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`.
- A flattened list overlay's `type` and `columns`: FROM `unknown` TO the list view's own types, both optional: `type` one of the list view types, `columns` a field list. This holds on the list overlay member of `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`. The member read both keys off the list view shape through a cast that erased them, so `{ object, viewKind: 'list', columns: 42 }` type-checked as all four while that member refuses it.

**If your code stops compiling.** A value you annotated with one of these names, or passed to `defineReport` / `defineViewItem`, is not the shape the door accepts: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. A ViewItem's `config` must match its `viewKind`: a `ListView` config under `viewKind: 'list'`, a `FormView` config under `viewKind: 'form'`. A flattened list overlay's `columns` is a field list and its `type` one of the list view types.

The declared types of `JoinedReportBlockSchema`, `ViewItemSchema` and `ViewItemWireSchema` narrow with them, so `z.input` / `z.infer` of each is typed where it was `unknown` (or carried an `unknown` `config`). Typed, each schema's input and output now differ by its defaults, so three ADR-0122 parsed-state aliases are added beside the bare names: `JoinedReportBlockParsed`, `ViewItemParsed` and `ViewItemWireParsed`. Nothing is removed or renamed.

One default is applied by the parse and is absent from `ViewMetadataParsed` / `AssembledViewArtifactParsed`, and their TSDoc now says so: the flattened list overlay member re-applies `type: 'grid'` in an `.overwrite()`, so every body it parses carries `type`, while its output type leaves `type` optional.

The types are the members' declared shapes, not the schemas' verdicts: refinements are not types, so each schema remains the only judge.

<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no existing export and no stored row changes (three parsed-state type names are added, none removed or renamed) and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->
3 changes: 3 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
"InterfacePageConfigParsed (type)",
"InterfacePageConfigSchema (const)",
"JoinedReportBlock (type)",
"JoinedReportBlockParsed (type)",
"JoinedReportBlockSchema (const)",
"KNOWN_COMPONENT_TYPES (const)",
"KNOWN_COMPONENT_TYPE_CANDIDATES (const)",
Expand Down Expand Up @@ -420,8 +421,10 @@
"ViewItem (type)",
"ViewItemName (type)",
"ViewItemNameSchema (const)",
"ViewItemParsed (type)",
"ViewItemSchema (const)",
"ViewItemWire (type)",
"ViewItemWireParsed (type)",
"ViewItemWireSchema (const)",
"ViewKeyCollision (interface)",
"ViewKind (type)",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@
"InterfacePageConfigParsed": "src/ui/page.zod.ts#InterfacePageConfigParsed (type)",
"InterfacePageConfigSchema": "src/ui/page.zod.ts#InterfacePageConfigSchema (const)",
"JoinedReportBlock": "src/ui/report.zod.ts#JoinedReportBlock (type)",
"JoinedReportBlockParsed": "src/ui/report.zod.ts#JoinedReportBlockParsed (type)",
"JoinedReportBlockSchema": "src/ui/report.zod.ts#JoinedReportBlockSchema (const)",
"KNOWN_COMPONENT_TYPES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPES (const)",
"KNOWN_COMPONENT_TYPE_CANDIDATES": "src/ui/component-type-vocabulary.ts#KNOWN_COMPONENT_TYPE_CANDIDATES (const)",
Expand Down Expand Up @@ -406,8 +407,10 @@
"ViewItem": "src/ui/view.zod.ts#ViewItem (type)",
"ViewItemName": "src/ui/view.zod.ts#ViewItemName (type)",
"ViewItemNameSchema": "src/ui/view.zod.ts#ViewItemNameSchema (const)",
"ViewItemParsed": "src/ui/view.zod.ts#ViewItemParsed (type)",
"ViewItemSchema": "src/ui/view.zod.ts#ViewItemSchema (const)",
"ViewItemWire": "src/ui/view.zod.ts#ViewItemWire (type)",
"ViewItemWireParsed": "src/ui/view.zod.ts#ViewItemWireParsed (type)",
"ViewItemWireSchema": "src/ui/view.zod.ts#ViewItemWireSchema (const)",
"ViewKeyCollision": "src/ui/view.zod.ts#ViewKeyCollision (interface)",
"ViewKind": "src/ui/view.zod.ts#ViewKind (type)",
Expand Down
26 changes: 20 additions & 6 deletions packages/spec/src/type-alias-convention.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js';
import type * as M188 from './ai/build-progress.zod.js';

// ---------------------------------------------------------------------------
// 786 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
Expand Down Expand Up @@ -1551,7 +1551,10 @@ export type Iso_ui_page__PageComponentType = Assert<Eq< z.input< typeof M163.Pag
export type Iso_ui_page__PageTypeSchema = Assert<Eq< z.input< typeof M163.PageTypeSchema >, z.infer< typeof M163.PageTypeSchema > >>;

// ui/report.zod.ts
export type Iso_ui_report__JoinedReportBlockSchema = Assert<Eq< z.input< typeof M164.JoinedReportBlockSchema >, z.infer< typeof M164.JoinedReportBlockSchema > >>;
// `JoinedReportBlockSchema` left the family on #19920: its `z.ZodTypeAny`
// annotation made input and infer the same `unknown`, and with the annotation
// gone its `type` default makes them differ, so `JoinedReportBlockParsed` is
// declared and the pin deleted.
export type Iso_ui_report__ReportType = Assert<Eq< z.input< typeof M164.ReportType >, z.infer< typeof M164.ReportType > >>;

// ui/responsive.zod.ts
Expand All @@ -1566,6 +1569,10 @@ export type Iso_ui_responsive__StyleMapSchema = Assert<Eq< z.input< typeof M165.
// ui/theme.zod.ts — its five pins (Iso700–Iso704) left with the module at #10485.

// ui/view.zod.ts
// `ViewItemSchema` and `ViewItemWireSchema` left the family on #19920: their
// `config` was `z.ZodTypeAny` (input and infer the same `unknown`), and typed by
// its arm it carries the list and form configs' defaults, so `ViewItemParsed` and
// `ViewItemWireParsed` are declared and both pins deleted.
// `KanbanConfigSchema` left this list as Iso829 when #17393 gave it a `limit`
// with an APPLIED default, and returns as Iso_ui_view__KanbanConfigSchema now
// that #19228 removed that member before it was published: the two shapes
Expand All @@ -1584,8 +1591,6 @@ export type Iso_ui_view__RowHeightSchema = Assert<Eq< z.input< typeof M167.RowHe
export type Iso_ui_view__TreeConfigSchema = Assert<Eq< z.input< typeof M167.TreeConfigSchema >, z.infer< typeof M167.TreeConfigSchema > >>;
export type Iso_ui_view__UserFilterFieldSchema = Assert<Eq< z.input< typeof M167.UserFilterFieldSchema >, z.infer< typeof M167.UserFilterFieldSchema > >>;
export type Iso_ui_view__ViewItemNameSchema = Assert<Eq< z.input< typeof M167.ViewItemNameSchema >, z.infer< typeof M167.ViewItemNameSchema > >>;
export type Iso_ui_view__ViewItemSchema = Assert<Eq< z.input< typeof M167.ViewItemSchema >, z.infer< typeof M167.ViewItemSchema > >>;
export type Iso_ui_view__ViewItemWireSchema = Assert<Eq< z.input< typeof M167.ViewItemWireSchema >, z.infer< typeof M167.ViewItemWireSchema > >>;
export type Iso_ui_view__ViewKindSchema = Assert<Eq< z.input< typeof M167.ViewKindSchema >, z.infer< typeof M167.ViewKindSchema > >>;
export type Iso_ui_view__ViewScopeSchema = Assert<Eq< z.input< typeof M167.ViewScopeSchema >, z.infer< typeof M167.ViewScopeSchema > >>;
export type Iso_ui_view__VisualizationTypeSchema = Assert<Eq< z.input< typeof M167.VisualizationTypeSchema >, z.infer< typeof M167.VisualizationTypeSchema > >>;
Expand Down Expand Up @@ -1658,7 +1663,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
it('still declares all 786 isomorphic pins', () => {
it('still declares all 783 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert<Eq<...>>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
Expand Down Expand Up @@ -2305,7 +2310,16 @@ describe('ADR-0122 type-alias convention', () => {
// touch disjoint pins (M22's three, M14's one); #17158 landed first, so
// this entry's arrow starts from its 787. The count below was re-derived
// from the merged file, not added up. -1 removed.
expect(pins).toHaveLength(786);
//
// 786 -> 783 is #19920's typing of three schemas whose static type had been
// erased, so that input and infer were the same `unknown` and the pins held
// vacuously: `JoinedReportBlockSchema` (its `z.ZodTypeAny` annotation
// removed) and `ViewItemSchema` / `ViewItemWireSchema` (their `config` typed
// by its arm). Typed, each carries defaults, so input !== infer:
// Iso_ui_report__JoinedReportBlockSchema, Iso_ui_view__ViewItemSchema and
// Iso_ui_view__ViewItemWireSchema leave, and `JoinedReportBlockParsed`,
// `ViewItemParsed` and `ViewItemWireParsed` are declared. -3 removed.
expect(pins).toHaveLength(783);

// The count is stated in PROSE twice as well — this case's title and the
// section header above the pin list — and until #6605 nothing read either
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/ui/assembled-views.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclu
/**
* Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122):
* the union of the same members' OUTPUT types, for the same reason.
*
* [#19920] One default is applied by the parse but absent from this type, the one
* `ViewMetadataParsed` (`view.zod.ts`) names: the flattened list overlay member declares `type`
* without the list shape's `.default('grid')` and re-applies it in an `.overwrite()`, which
* returns the member's own output type. So on that member `type` stays optional here (typed as
* the list shape's `type` enum), while every body it parses comes back with `type` set: `'grid'`
* when the body named none.
*/
export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude<ViewMetadataBranch, 'container'>]>;

Expand Down
82 changes: 82 additions & 0 deletions packages/spec/src/ui/joined-report-block-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#19920] The published type `JoinedReportBlock` names one sub-report of a joined report; it is
* not `unknown`, and neither is a `blocks[]` element of `Report` / `ReportParsed`.
*
* `JoinedReportBlockSchema` was annotated `z.ZodTypeAny`, so `z.input<typeof …>` of it WAS
* `unknown`, and `ReportSchema`'s `blocks: z.array(JoinedReportBlockSchema)` was `unknown[]`: any
* value type-checked as a block. The schema now carries its inferred type.
*
* Two halves, judged by two programs (the `view-metadata-type.test.ts` shape):
*
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
* `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line
* does NOT compile. While the block type was `unknown` every one of them compiled, so each
* directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the
* gate.
* - The RUNTIME half ties the typed bodies to the doors: each one parses, and the joined report's
* parsed `blocks` are values of the block's output type.
*/

import { describe, it, expect } from 'vitest';
import {
JoinedReportBlockSchema,
ReportSchema,
type JoinedReportBlock,
type JoinedReportBlockParsed,
type Report,
type ReportParsed,
} from './report.zod';

type ParsedBlock = NonNullable<ReportParsed['blocks']>[number];

// ── Real bodies, each typed through the published names ──────────────────────────────────────

const openBlock: JoinedReportBlock = {
name: 'open_block',
label: 'Open Tasks',
type: 'summary',
dataset: 'task_metrics',
rows: ['status'],
values: ['est_hours'],
order: [{ by: 'est_hours', direction: 'desc' }],
};
const listBlock: JoinedReportBlock = { name: 'done_block', dataset: 'task_metrics', values: ['task_count'] };
const joined: Report = { name: 'task_overview', label: 'Task Overview', type: 'joined', blocks: [openBlock, listBlock] };

// ── What the block type refuses at compile time ──────────────────────────────────────────────

const someValue: unknown = JSON.parse('{"nope":1}');
// @ts-expect-error -- `unknown` is not a block; it was assignable while JoinedReportBlock was `unknown`.
const fromUnknown: JoinedReportBlock = someValue;
// @ts-expect-error -- a block is an object.
const scalar: JoinedReportBlock = 42;
// @ts-expect-error -- `notABlockKey` is declared by no block (TS2353).
const undeclaredKey: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', notABlockKey: 1 };
// @ts-expect-error -- `chart` was removed from the block (#20161); the closed shape refuses it too.
const retiredChart: JoinedReportBlock = { name: 'b', dataset: 'task_metrics', chart: { type: 'bar' } };
// @ts-expect-error -- `joined` is excluded from a block's type enum (no recursion).
const nestedJoined: JoinedReportBlock = { name: 'b', type: 'joined' };
// @ts-expect-error -- a `blocks[]` element of Report is a block, not any value.
const reportWithScalarBlock: Report = { name: 'r', label: 'R', type: 'joined', blocks: [42] };
// @ts-expect-error -- nor is a parsed one.
const parsedScalarBlock: ParsedBlock = 42;
void [fromUnknown, scalar, undeclaredKey, retiredChart, nestedJoined, reportWithScalarBlock, parsedScalarBlock];

describe('[#19920] JoinedReportBlock is a joined-report block, not unknown', () => {
it('each block typed as JoinedReportBlock parses at the block door', () => {
for (const block of [openBlock, listBlock]) {
expect(JoinedReportBlockSchema.safeParse(block).success).toBe(true);
}
});

it('a joined report typed as Report parses, and its parsed blocks are JoinedReportBlockParsed', () => {
const parsed: ReportParsed = ReportSchema.parse(joined);
const blocks: JoinedReportBlockParsed[] = parsed.blocks ?? [];
expect(blocks.map((b) => [b.name, b.type])).toEqual([
['open_block', 'summary'],
['done_block', 'tabular'],
]);
});
});
21 changes: 20 additions & 1 deletion packages/spec/src/ui/report.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,15 @@ const JOINED_CONTAINER_CHART_REFUSED =
* - A block is drawn as a table and has no `chart` key: #20161 removed it,
* because nothing ever drew it. Writing it is refused with the upgrade
* prescription (the `guidance` entry below).
*
* [#19920] Carries its inferred type, not a `z.ZodTypeAny` annotation. That
* annotation erased the block's shape, so {@link JoinedReportBlock} and every
* `blocks[]` element of {@link Report} / {@link ReportParsed} were `unknown`
* and any value type-checked against them. It dodged no TS7056 (measured: none
* without it); what it bought was declaration size, the block's shape being
* emitted once here and once inside `ReportSchema`'s `blocks`.
*/
export const JoinedReportBlockSchema: z.ZodTypeAny = lazySchema(() => strictObject({
export const JoinedReportBlockSchema = lazySchema(() => strictObject({
surface: 'this joined report block',
history:
'Until this shape was closed these were dropped silently — the block still rendered, '
Expand Down Expand Up @@ -543,7 +550,19 @@ export const ReportSchema = lazySchema(() => strictObject({
}
}));

/**
* One sub-report of a `type: 'joined'` report (input shape): the input type of
* {@link JoinedReportBlockSchema}.
*
* [#19920] Was `unknown` while that schema was annotated `z.ZodTypeAny`.
* `joined-report-block-type.test.ts` pins that `unknown`, an undeclared key and
* the retired `chart` are refused here. A static type, not the schema's
* verdict: the `order` check against the selected dimensions and measures is a
* refinement, not a type, so `JoinedReportBlockSchema` remains the only judge.
*/
export type JoinedReportBlock = z.input<typeof JoinedReportBlockSchema>;
/** Post-parse shape of {@link JoinedReportBlock} — defaults applied, transforms run (ADR-0122). */
export type JoinedReportBlockParsed = z.infer<typeof JoinedReportBlockSchema>;

/**
* Report Types
Expand Down
Loading
Loading