Skip to content

Commit aa5156d

Browse files
committed
fix(spec): declare ViewMetadata as the union of its members' input types, not unknown
ViewMetadata was z.input of ViewMetadataSchema, a z.preprocess whose input type is unknown, so the published name type-checked any value. It is now read off VIEW_METADATA_MEMBERS, the members the schema's union runs. The runtime schema is unchanged. A type-level pin (view-metadata-type.test.ts) refuses unknown, a key no member declares and a non-object, and types one body per member that the door accepts through that member. The metadata serializer's comment that documented the unknown now states why view stays unannotated. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8490127 commit aa5156d

4 files changed

Lines changed: 108 additions & 6 deletions

File tree

‎packages/metadata/src/serializers/typescript-serializer-annotation.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* - a spec-valid body must type-check with no diagnostic at all;
1313
* - the same body plus one undeclared key must fail with exactly TS2353. That
1414
* is what makes the annotation a check: a spec type that is `unknown` (as
15-
* `ViewMetadata` is) would pass the first file and the second.
15+
* `ViewMetadata` once was) would pass the first file and the second.
1616
*
1717
* `@objectstack/spec` is resolved the way a consumer of a saved file resolves
1818
* it, through this package's `node_modules` and the `exports` map's `types`,

‎packages/metadata/src/serializers/typescript-serializer.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ import { sortObjectKeys } from './sort-object-keys.js';
2727
* `unknown` or another type's shape.
2828
*
2929
* Two metadata types are deliberately absent:
30-
* - `view`: `ViewMetadataSchema` is a `z.preprocess`, so its input type, and
31-
* with it `ViewMetadata`, is `unknown`. Annotating with it would check
32-
* nothing.
30+
* - `view`: `ViewMetadataSchema` is a `z.preprocess`, so its input type is
31+
* `unknown`. `ViewMetadata` is declared instead as the union of the input
32+
* types of the members the schema's union runs, so it is not the bound
33+
* schema's `z.input` and this table's rule excludes it. The rule holds for a
34+
* reason here: the view door accepts bodies `ViewMetadata` refuses (the
35+
* preprocess removes the console's row `id`s before any member judges the
36+
* body), so annotating a saved view with it could be false.
3337
* - `book`: `Book` is written by hand and lacks the `_packageId` /
3438
* `_provenance` protection keys `BookSchema` accepts, so a book the loader
3539
* has stamped would fail against it.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19871] The published type `ViewMetadata` names a `view` body; it is not `unknown`.
5+
*
6+
* `ViewMetadataSchema` is a `z.preprocess`, whose input type is `unknown`, so the type declared as
7+
* `z.input<typeof ViewMetadataSchema>` WAS `unknown`: any value type-checked against a name and a
8+
* TSDoc that promise a persisted view body. It is now the union of the input types of
9+
* `VIEW_METADATA_MEMBERS`, the members the schema's union runs.
10+
*
11+
* Two halves, judged by two programs:
12+
*
13+
* - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via
14+
* `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line
15+
* does NOT compile. While `ViewMetadata` was `unknown` every one of them compiled, so each
16+
* directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry, which reds the
17+
* gate. The member bodies are typed through the published name, so a later narrowing that drops
18+
* a member is a compile error on its line.
19+
* - The RUNTIME half ties those typed bodies to the door: each one parses, through the member it
20+
* is written for, so the fixture really covers every member.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import {
25+
VIEW_METADATA_BRANCHES,
26+
diagnoseViewMetadata,
27+
type ViewMetadata,
28+
type ViewMetadataBranch,
29+
} from './view.zod';
30+
31+
// ── One body per member, each typed through the published name ────────────────────────────────
32+
33+
const viewItem: ViewMetadata = {
34+
name: 'crm_lead.all',
35+
object: 'crm_lead',
36+
viewKind: 'list',
37+
config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] },
38+
};
39+
const container: ViewMetadata = {
40+
object: 'crm_lead',
41+
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] },
42+
};
43+
const listOverlay: ViewMetadata = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list' };
44+
const formOverlay: ViewMetadata = {
45+
type: 'simple',
46+
sections: [{ label: 'Main', fields: ['name'] }],
47+
object: 'crm_lead',
48+
viewKind: 'form',
49+
};
50+
51+
const BODY_OF_EACH_MEMBER: Record<ViewMetadataBranch, ViewMetadata> = {
52+
viewItem,
53+
container,
54+
listOverlay,
55+
formOverlay,
56+
};
57+
58+
// ── What `ViewMetadata` refuses at compile time ──────────────────────────────────────────────
59+
60+
const someValue: unknown = JSON.parse('{"nope":1}');
61+
// @ts-expect-error -- `unknown` is not a view body; it was assignable while ViewMetadata was `unknown`.
62+
const fromUnknown: ViewMetadata = someValue;
63+
// @ts-expect-error -- `notAViewKey` is declared by no member (TS2353).
64+
const undeclaredKey: ViewMetadata = { type: 'grid', columns: ['name'], object: 'crm_lead', viewKind: 'list', notAViewKey: 1 };
65+
// @ts-expect-error -- a view body is an object.
66+
const scalar: ViewMetadata = 42;
67+
void [fromUnknown, undeclaredKey, scalar];
68+
69+
describe('[#19871] ViewMetadata is a view body, not unknown', () => {
70+
it('has a typed body for every member of the union', () => {
71+
expect(Object.keys(BODY_OF_EACH_MEMBER).sort()).toEqual([...VIEW_METADATA_BRANCHES].sort());
72+
});
73+
74+
for (const branch of VIEW_METADATA_BRANCHES) {
75+
it(`the ${branch} body typed as ViewMetadata parses, through the ${branch} member`, () => {
76+
const diagnosis = diagnoseViewMetadata(BODY_OF_EACH_MEMBER[branch]);
77+
expect(diagnosis.success).toBe(true);
78+
expect(diagnosis.branch).toBe(branch);
79+
});
80+
}
81+
});

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6306,8 +6306,25 @@ export type ViewParsed = z.infer<typeof ViewSchema>;
63066306
export type ViewItem = z.input<typeof ViewItemSchema>;
63076307
/** A ViewItem record as it travels the WIRE — the authoring shape plus Studio's round-trip keys (#5074). */
63086308
export type ViewItemWire = z.input<typeof ViewItemWireSchema>;
6309-
/** Any persisted `view` metadata body: container | ViewItem record | flattened overlay (#3095). */
6310-
export type ViewMetadata = z.input<typeof ViewMetadataSchema>;
6309+
/**
6310+
* Any persisted `view` metadata body: container | ViewItem record | flattened overlay (#3095) —
6311+
* the union of the INPUT types of the members {@link ViewMetadataSchema}'s union runs, read off
6312+
* {@link VIEW_METADATA_MEMBERS} (the union's member list by construction).
6313+
*
6314+
* [#19871] Deliberately NOT `z.input<typeof ViewMetadataSchema>`. That schema is a `z.preprocess`,
6315+
* whose input type is `unknown`, and its union's members are cast to `z.ZodTypeAny` where the
6316+
* union is built — so every type derived from the schema itself is `unknown`, and this name used to
6317+
* type-check any body at all. `view-metadata-type.test.ts` pins that `unknown` and an undeclared
6318+
* key are refused here, and that a body of each member still type-checks.
6319+
*
6320+
* A static type, not the door's verdict, in both directions: the door accepts bodies this type
6321+
* refuses (the preprocess removes the console's row `id`s, and three members strip undeclared
6322+
* top-level keys), and refuses bodies it admits — the identity precondition, the members'
6323+
* refinements, and a body that mixes keys of different members, because TypeScript checks an
6324+
* object literal's keys against the union as a whole and the container member's keys are all
6325+
* optional. `ViewMetadataSchema` remains the only judge.
6326+
*/
6327+
export type ViewMetadata = z.input<(typeof VIEW_METADATA_MEMBERS)[ViewMetadataBranch]>;
63116328
/** Post-parse shape of {@link ViewMetadata} — defaults applied, transforms run (ADR-0122). */
63126329
export type ViewMetadataParsed = z.infer<typeof ViewMetadataSchema>;
63136330
export type ViewScope = z.input<typeof ViewScopeSchema>;

0 commit comments

Comments
 (0)