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
23 changes: 23 additions & 0 deletions .changeset/19920-exported-types-not-unknown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
---

fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920)

Clause-②: no (narrowing)

**BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: 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, no parse and no export changes, and neither does the declared type of any schema.

Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs:

- `InlineAction`: FROM `z.input<typeof InlineActionSchema>` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object.
- `ViewMetadataParsed`: FROM `z.infer<typeof ViewMetadataSchema>` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes.
- `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema.

**If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`.

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`.

<!-- 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. -->
2 changes: 1 addition & 1 deletion .changeset/view-metadata-type-not-unknown.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand Down Expand Up @@ -30,7 +30,7 @@
member shapes. Correct the body, or type a value that is still unvalidated as `unknown` and let
`ViewMetadataSchema.safeParse` decide.

`ViewMetadataParsed` is not changed by this release: it is still `unknown`.
`ViewMetadataParsed` is not changed by this change. It is re-derived from the same members, as their output types, by its own entry (#19920).

The `@objectstack/metadata` changelog entry for #19852 gives `ViewMetadata` being `unknown` as the
reason a saved `view` file is written with no annotation; that reason is superseded here, and the
Expand Down
17 changes: 16 additions & 1 deletion packages/spec/src/ui/action.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2158,7 +2158,22 @@ export const InlineActionSchema = lazySchema(() => z.preprocess(
}),
));

export type InlineAction = z.input<typeof InlineActionSchema>;
/**
* An inline action as an author writes it: the INPUT type of the object {@link InlineActionSchema}
* hands its body to, read off the pipe's `out` member.
*
* [#19920] Deliberately NOT `z.input<typeof InlineActionSchema>`. That schema is a `z.preprocess`,
* whose input type is the preprocess function's parameter — `unknown` for
* {@link normalizeInlineAction} — so this name used to type-check any value at all. The pipe's
* `out` member is the `.pick()`ed object itself, so the type now carries the shape its fields
* declare. `inline-action-type.test.ts` pins both halves.
*
* A static type, not the door's verdict, in both directions: the door accepts bodies this type
* refuses (the legacy `type: 'navigation'` and `to` spellings, which the preprocess folds onto
* `url` / `target`, are canonical-only here on purpose), and refuses bodies it admits (the
* `target`-required refinement is not a type). `InlineActionSchema` remains the only judge.
*/
export type InlineAction = z.input<(typeof InlineActionSchema)['out']>;
/** Post-parse shape of {@link InlineAction} — defaults applied, transforms run (ADR-0122). */
export type InlineActionParsed = z.infer<typeof InlineActionSchema>;

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

/**
* [#19920] The published types `AssembledViewArtifact` / `AssembledViewArtifactParsed` name a
* non-container view artifact; they are not `unknown`.
*
* `AssembledViewArtifactSchema`'s union is built from `VIEW_METADATA_MEMBERS` values cast to
* `z.ZodTypeAny`, so both types, derived from the schema itself, WERE `unknown`: any value — a
* container included — type-checked against a name that promises one `viewItems:` entry. They are
* now the union of the input (resp. output) types of the three non-container members.
*
* 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 types were `unknown` every one of them compiled, so each directive
* was unused (TS2578), which reds the gate. The bodies are typed through the published names, so
* a later narrowing that drops a member is a compile error on its line.
* - The RUNTIME half ties those typed bodies to the schema: each one parses, and its parse output
* is a value of `AssembledViewArtifactParsed`.
*/

import { describe, it, expect } from 'vitest';
import {
AssembledViewArtifactSchema,
type AssembledViewArtifact,
type AssembledViewArtifactParsed,
} from './assembled-views.zod';
import { VIEW_METADATA_BRANCHES, VIEW_METADATA_MEMBERS, type ViewMetadataBranch } from './view.zod';

type ArtifactBranch = Exclude<ViewMetadataBranch, 'container'>;

// ── One body per non-container member, each typed through the published name ─────────────────

const viewItem: AssembledViewArtifact = {
name: 'crm_lead.all',
object: 'crm_lead',
viewKind: 'list',
config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] },
};
const listOverlay: AssembledViewArtifact = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list' };
const formOverlay: AssembledViewArtifact = {
type: 'simple',
sections: [{ label: 'Main', fields: ['name'] }],
object: 'crm_lead',
viewKind: 'form',
};

const BODY_OF_EACH_MEMBER: Record<ArtifactBranch, AssembledViewArtifact> = { viewItem, listOverlay, formOverlay };

// ── What the two types refuse at compile time ────────────────────────────────────────────────

const someValue: unknown = JSON.parse('{"nope":1}');
// @ts-expect-error -- `unknown` is not an artifact; it was assignable while AssembledViewArtifact was `unknown`.
const fromUnknown: AssembledViewArtifact = someValue;
const CONTAINER_LIST = { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] } as const;
// @ts-expect-error -- a container travels in `views:`, never in `viewItems:`; no artifact member admits a `list` config.
const container: AssembledViewArtifact = { object: 'crm_lead', list: CONTAINER_LIST };
// @ts-expect-error -- `notAViewKey` is declared by no member (TS2353).
const undeclaredKey: AssembledViewArtifact = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list', notAViewKey: 1 };
// @ts-expect-error -- `unknown` is not a parsed artifact either; it was assignable while AssembledViewArtifactParsed was `unknown`.
const parsedFromUnknown: AssembledViewArtifactParsed = someValue;
void [fromUnknown, container, undeclaredKey, parsedFromUnknown];

describe('[#19920] AssembledViewArtifact is a non-container view artifact, not unknown', () => {
it('has a typed body for every non-container member', () => {
expect(Object.keys(BODY_OF_EACH_MEMBER).sort()).toEqual(
VIEW_METADATA_BRANCHES.filter((branch) => branch !== 'container').sort(),
);
});

for (const branch of Object.keys(BODY_OF_EACH_MEMBER) as ArtifactBranch[]) {
it(`the ${branch} body typed as AssembledViewArtifact parses, through the ${branch} member`, () => {
const body = BODY_OF_EACH_MEMBER[branch];
expect(AssembledViewArtifactSchema.safeParse(body).success).toBe(true);
const parsed: AssembledViewArtifactParsed = VIEW_METADATA_MEMBERS[branch].parse(body);
expect(parsed).toMatchObject({ object: 'crm_lead' });
});
}
});
26 changes: 22 additions & 4 deletions packages/spec/src/ui/assembled-views.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
VIEW_METADATA_MEMBERS,
expandViewContainer,
isAggregatedViewContainer,
type ViewMetadataBranch,
} from './view.zod';

/**
Expand Down Expand Up @@ -98,10 +99,27 @@ export const AssembledViewArtifactSchema = lazySchema(() => {
);
});

/** One assembled `viewItems:` entry (input shape). */
export type AssembledViewArtifact = z.input<typeof AssembledViewArtifactSchema>;
/** Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122). */
export type AssembledViewArtifactParsed = z.infer<typeof AssembledViewArtifactSchema>;
/**
* One assembled `viewItems:` entry (input shape): the union of the INPUT types of the
* non-container members of {@link VIEW_METADATA_MEMBERS} — the same members
* {@link AssembledViewArtifactSchema}'s union is mapped from, so the two cannot drift.
*
* [#19920] Deliberately NOT `z.input<typeof AssembledViewArtifactSchema>`: the union's members are
* cast to `z.ZodTypeAny` where it is built, so every type derived from the schema itself is
* `unknown`, and this name used to type-check any value — a container included.
* `assembled-view-artifact-type.test.ts` pins that `unknown` and a container are refused here and
* that a body of each member type-checks.
*
* A static type, not the schema's verdict: the members' refinements are not types, and TypeScript
* checks an object literal's keys against the union as a whole. `AssembledViewArtifactSchema`
* remains the only judge.
*/
export type AssembledViewArtifact = z.input<(typeof VIEW_METADATA_MEMBERS)[Exclude<ViewMetadataBranch, 'container'>]>;
/**
* Post-parse shape of {@link AssembledViewArtifact} — defaults applied, transforms run (ADR-0122):
* the union of the same members' OUTPUT types, for the same reason.
*/
export type AssembledViewArtifactParsed = z.infer<(typeof VIEW_METADATA_MEMBERS)[Exclude<ViewMetadataBranch, 'container'>]>;

/** Result of {@link partitionAssembledViewArtifacts}. */
export interface AssembledViewPartition {
Expand Down
73 changes: 73 additions & 0 deletions packages/spec/src/ui/inline-action-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#19920] The published type `InlineAction` names an inline action body; it is not `unknown`.
*
* `InlineActionSchema` is a `z.preprocess`, whose input type is the preprocess function's
* parameter (`unknown`), so the type declared as `z.input<typeof InlineActionSchema>` WAS
* `unknown`: any value type-checked against a name that promises an inline action. It is now the
* input type of the pipe's `out` member, the `.pick()`ed action object.
*
* 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 `InlineAction` 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. Every key of the input shape is optional (`type` has a default, `name` and `label` are
* `.partial()`), so there is no missing-required-key case to pin here.
* - The RUNTIME half ties the typed bodies to the door: each one parses. It also pins the one
* direction the TSDoc states in words — the door still ACCEPTS the legacy `navigation` / `to`
* spellings this type refuses, folding them onto `url` / `target`.
*/

import { describe, it, expect } from 'vitest';
import { InlineActionSchema, type InlineAction } from './action.zod';

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

const urlAction: InlineAction = { type: 'url', target: '/pricing', openIn: 'new-tab' };
const apiAction: InlineAction = {
type: 'api',
target: '/api/v1/billing/refresh',
method: 'POST',
bodyExtra: { plan: 'pro' },
confirmText: 'Refresh billing?',
successMessage: 'Refreshed',
refreshAfter: true,
};
const namedModal: InlineAction = { name: 'open_upgrade', label: 'Upgrade', type: 'modal', target: 'upgrade_dialog' };

// ── What `InlineAction` refuses at compile time ──────────────────────────────────────────────

const someValue: unknown = JSON.parse('{"nope":1}');
// @ts-expect-error -- `unknown` is not an inline action; it was assignable while InlineAction was `unknown`.
const fromUnknown: InlineAction = someValue;
// @ts-expect-error -- `navigation` is a legacy spelling the preprocess folds; the type names canonical `type` values only.
const legacyType: InlineAction = { type: 'navigation', target: '/pricing' };
// @ts-expect-error -- `to` is a legacy spelling of `target`, declared by no field (TS2353).
const legacyTo: InlineAction = { type: 'url', to: '/pricing' };
// @ts-expect-error -- an inline action is an object.
const scalar: InlineAction = 42;
void [fromUnknown, scalar];

describe('[#19920] InlineAction is an inline action body, not unknown', () => {
it.each([
['a url action', urlAction],
['an api action with a static payload', apiAction],
['a named modal action', namedModal],
])('%s typed as InlineAction parses', (_label, body) => {
expect(InlineActionSchema.safeParse(body).success).toBe(true);
});

it('the door still accepts the legacy spellings the type refuses, folding them onto url / target', () => {
const typeFold = InlineActionSchema.safeParse(legacyType);
expect(typeFold.success).toBe(true);
expect(typeFold.data).toMatchObject({ type: 'url', target: '/pricing' });

const targetFold = InlineActionSchema.safeParse(legacyTo);
expect(targetFold.success).toBe(true);
expect(targetFold.data).toMatchObject({ type: 'url', target: '/pricing' });
expect(targetFold.data).not.toHaveProperty('to');
});
});
26 changes: 26 additions & 0 deletions packages/spec/src/ui/view-metadata-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
import { describe, it, expect } from 'vitest';
import {
VIEW_METADATA_BRANCHES,
VIEW_METADATA_MEMBERS,
diagnoseViewMetadata,
type ViewMetadata,
type ViewMetadataBranch,
type ViewMetadataParsed,
} from './view.zod';

// ── One body per member, each typed through the published name ────────────────────────────────
Expand Down Expand Up @@ -66,6 +68,16 @@ const undeclaredKey: ViewMetadata = { type: 'grid', columns: ['name'], object: '
const scalar: ViewMetadata = 42;
void [fromUnknown, undeclaredKey, scalar];

// ── [#19920] `ViewMetadataParsed`: the members' OUTPUT union, refused the same way ─────────────

// @ts-expect-error -- `unknown` is not a parsed view body; it was assignable while ViewMetadataParsed was `unknown`.
const parsedFromUnknown: ViewMetadataParsed = someValue;
// @ts-expect-error -- `notAViewKey` is declared by no member's output (TS2353).
const parsedUndeclaredKey: ViewMetadataParsed = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list', notAViewKey: 1 };
// @ts-expect-error -- a parsed view body is an object.
const parsedScalar: ViewMetadataParsed = 42;
void [parsedFromUnknown, parsedUndeclaredKey, parsedScalar];

describe('[#19871] ViewMetadata is a view body, not unknown', () => {
it('has a typed body for every member of the union', () => {
expect(Object.keys(BODY_OF_EACH_MEMBER).sort()).toEqual([...VIEW_METADATA_BRANCHES].sort());
Expand All @@ -79,3 +91,17 @@ describe('[#19871] ViewMetadata is a view body, not unknown', () => {
});
}
});

describe('[#19920] ViewMetadataParsed is a parsed view body, not unknown', () => {
for (const branch of VIEW_METADATA_BRANCHES) {
it(`the ${branch} member's parse output and diagnoseViewMetadata's data are both ViewMetadataParsed`, () => {
const memberOutput: ViewMetadataParsed = VIEW_METADATA_MEMBERS[branch].parse(BODY_OF_EACH_MEMBER[branch]);
const diagnosis = diagnoseViewMetadata(BODY_OF_EACH_MEMBER[branch]);
if (!diagnosis.success) throw new Error(`the ${branch} body must parse`);
const data: ViewMetadataParsed = diagnosis.data;
// The assertion in diagnoseViewMetadata changes no value: its data is the union's output,
// which is the accepting member's own output.
expect(data).toEqual(memberOutput);
});
}
});
Loading
Loading