From 046b58d47dfa4d5bb3bef3a3107ce24732ddb91b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:29:47 +0000 Subject: [PATCH 1/2] fix(spec): validate all three runtime view shapes in the `view` type-schema (#3095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `view` metadata type mapped to the aggregate container `ViewSchema` (`{ list, form, listViews, formViews }`, all slots optional). Zod strips unknown keys, so the two non-container shapes a `view` body carries at runtime — a standalone ViewItem record (`{ name, object, viewKind, config }`) and a console personalization overlay (raw config + inherited identity, #2555) — both strip-parsed to `{}`. That made the 422 check in `saveMetaItem` and read-time `computeMetadataDiagnostics` a no-op for them: a broken `config` (e.g. a kanban missing `groupByField`) saved with a false 200 and badged valid. Map `view` to a new `ViewMetadataSchema` — a union over the three shapes, each validated genuinely: 1. non-empty defineView container (empty container rejected), 2. ViewItem record (nested config validated against ListView/FormView), 3. flattened personalization overlay (inline config + identity), with structural guards pinning config/list/form/listViews/formViews to undefined so a malformed record/container is never rescued through the lenient branch with its payload stripped. All members strip-parse (no .strict()) so aux Studio keys still ride along without a false 422; saveMetaItem keeps persisting verbatim. toJSONSchema emits an anyOf of the four members for /api/v1/meta/types/view. Adds regression tests: schema-level (all three shapes + JSON Schema), save-path 422 for a bad ViewItem, and computeMetadataDiagnostics badges. Fixes #3095. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LFmMsWLC3CVULh3Ke7rUdd --- ...iew-metadata-type-schema-runtime-shapes.md | 15 ++ .../objectql/src/metadata-diagnostics.test.ts | 55 ++++- .../protocol-view-identity-overlay.test.ts | 38 ++++ .../spec/src/kernel/metadata-type-schemas.ts | 8 +- .../spec/src/ui/view-metadata-schema.test.ts | 200 ++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 110 ++++++++++ 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 .changeset/view-metadata-type-schema-runtime-shapes.md create mode 100644 packages/spec/src/ui/view-metadata-schema.test.ts diff --git a/.changeset/view-metadata-type-schema-runtime-shapes.md b/.changeset/view-metadata-type-schema-runtime-shapes.md new file mode 100644 index 0000000000..ac51178814 --- /dev/null +++ b/.changeset/view-metadata-type-schema-runtime-shapes.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": patch +--- + +**The `view` metadata type-schema now validates all three runtime `view` shapes instead of stripping two of them to `{}`.** `metadata-type-schemas.ts` mapped `view` to the aggregate container `ViewSchema` (`{ list, form, listViews, formViews }`, every slot optional). Zod strips unknown keys, so the two non-container shapes a `view` body actually carries at runtime — a standalone **ViewItem record** (`{ name, object, viewKind, config }`) and a **console personalization overlay** (raw view config + identity inherited by `normalizeViewMetadata`, #2555) — both strip-parsed to `{}`. That made the `422` check in `saveMetaItem` and read-time `computeMetadataDiagnostics` a **no-op** for those shapes: a broken `config` (e.g. a kanban missing `groupByField`) saved with a false `200` and badged valid, and the view create-seed test validated against nothing. + +`view` now maps to a new `ViewMetadataSchema` — a union over the three shapes, each validated genuinely: + +1. **defineView container** — non-empty (`ViewSchema` refined to require at least one of `list`/`form`/`listViews`/`formViews`; an empty container is rejected, mirroring `defineView`). +2. **ViewItem record** — `ViewItemSchema`; the nested `config` is validated against ListView/FormView. +3. **Flattened personalization overlay** — inline ListView/FormView config plus optional identity fields. Structural guards pin `config`/`list`/`form`/`listViews`/`formViews` to `undefined` so a malformed record or container can never be rescued through this lenient branch with its real payload silently stripped. + +All members strip-parse (no `.strict()`), so auxiliary Studio round-trip keys (`isPinned`, `sortOrder`, …) still ride along without a false `422`, and `saveMetaItem` keeps persisting the body verbatim. `z.toJSONSchema()` emits the schema as an `anyOf` of the four members, which `/api/v1/meta/types/view` serves to Studio's SchemaForm. + +Fixes #3095. diff --git a/packages/objectql/src/metadata-diagnostics.test.ts b/packages/objectql/src/metadata-diagnostics.test.ts index f31b0fe00b..7bffa8f1c2 100644 --- a/packages/objectql/src/metadata-diagnostics.test.ts +++ b/packages/objectql/src/metadata-diagnostics.test.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest'; -import { computeViewReferenceDiagnostics } from '@objectstack/metadata-protocol'; +import { computeViewReferenceDiagnostics, computeMetadataDiagnostics } from '@objectstack/metadata-protocol'; const objectDef = { fields: { @@ -81,3 +81,56 @@ describe('computeViewReferenceDiagnostics (ADR-0047)', () => { expect(computeViewReferenceDiagnostics({}, {}).valid).toBe(true); }); }); + +/** + * #3095 — `computeMetadataDiagnostics('view', …)` must see through the two + * runtime `view` shapes the old container schema strip-parsed to `{}` (a + * standalone ViewItem record and a personalization overlay). Before the fix it + * returned `{ valid: true }` unconditionally for both, so Studio's badge never + * flagged a broken enumerated ViewItem. These pin the schema-backed diagnostics. + */ +describe('computeMetadataDiagnostics — view shapes (#3095)', () => { + it('flags a ViewItem whose kanban config is missing groupByField', () => { + const diag = computeMetadataDiagnostics('view', { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'kanban', columns: ['name'], kanban: { summarizeField: 'amount', columns: ['name'] } }, + }); + expect(diag?.valid).toBe(false); + expect(diag?.errors?.length).toBeGreaterThan(0); + }); + + it('passes a well-formed ViewItem', () => { + const diag = computeMetadataDiagnostics('view', { + name: 'crm_lead.all', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_lead' } }, + }); + expect(diag?.valid).toBe(true); + }); + + it('passes a personalization overlay body (badges do not regress)', () => { + const diag = computeMetadataDiagnostics('view', { + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title'], + sort: [{ id: 'x', field: 'estimate_hours', order: 'desc' }], + name: 'showcase_task.default', + viewKind: 'list', + object: 'showcase_task', + label: 'All Tasks', + }); + expect(diag?.valid).toBe(true); + }); + + it('still validates the defineView container shape', () => { + expect(computeMetadataDiagnostics('view', { + list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: ['name'] }, + })?.valid).toBe(true); + expect(computeMetadataDiagnostics('view', { + list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } }, // no columns + })?.valid).toBe(false); + }); +}); diff --git a/packages/objectql/src/protocol-view-identity-overlay.test.ts b/packages/objectql/src/protocol-view-identity-overlay.test.ts index 0468c0b177..74729525f8 100644 --- a/packages/objectql/src/protocol-view-identity-overlay.test.ts +++ b/packages/objectql/src/protocol-view-identity-overlay.test.ts @@ -168,6 +168,44 @@ describe('view overlay identity (#2555)', () => { expect(item.label).toBe('My Renamed Grid'); }); + // #3095 — a standalone ViewItem record's `config` used to strip to `{}` + // under the container ViewSchema, so a broken config saved with a false 200. + // The `view` type now maps to the union schema that validates it genuinely. + it('write path rejects a ViewItem whose kanban config is broken (422)', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await expect( + protocol.saveMetaItem({ + type: 'view', + name: 'crm_lead.pipeline', + item: { + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + // kanban config is missing the required groupByField. + config: { type: 'kanban', columns: ['name'], kanban: { summarizeField: 'amount', columns: ['name'] } }, + }, + }), + ).rejects.toMatchObject({ code: 'invalid_metadata', status: 422 }); + }); + + it('write path accepts a well-formed ViewItem record', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const result = await protocol.saveMetaItem({ + type: 'view', + name: 'crm_lead.all', + item: { + name: 'crm_lead.all', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_lead' } }, + }, + }); + expect(result.success).toBe(true); + expect(Array.from(rows.values()).some((r) => r.type === 'view')).toBe(true); + }); + it('write path stays a plain name-stamp when the registry has no entry to inherit from', async () => { const { engine, rows } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index b9b4c31628..f922247a3e 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -33,7 +33,7 @@ import { DatasourceSchema } from '../data/datasource.zod'; import { SeedSchema } from '../data/seed.zod'; import { MappingSchema } from '../data/mapping.zod'; -import { ViewSchema } from '../ui/view.zod'; +import { ViewMetadataSchema } from '../ui/view.zod'; import { PageSchema } from '../ui/page.zod'; import { DashboardSchema } from '../ui/dashboard.zod'; import { AppSchema } from '../ui/app.zod'; @@ -75,7 +75,11 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = mapping: MappingSchema as unknown as z.ZodType, // #2611: reusable import mapping; runtime-creatable so the wizard can save one // UI Protocol - view: ViewSchema, + // #3095 — a union over the three runtime `view` shapes (defineView container, + // standalone ViewItem record, flattened personalization overlay). The bare + // container `ViewSchema` strip-parsed ViewItem/personalization bodies to `{}`, + // making save-time 422 validation and read-time diagnostics a no-op for them. + view: ViewMetadataSchema, page: PageSchema, dashboard: DashboardSchema, app: AppSchema, diff --git a/packages/spec/src/ui/view-metadata-schema.test.ts b/packages/spec/src/ui/view-metadata-schema.test.ts new file mode 100644 index 0000000000..3e53f70f35 --- /dev/null +++ b/packages/spec/src/ui/view-metadata-schema.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3095 — `ViewMetadataSchema` is the canonical schema the `view` metadata type + * registers (save-time 422 validation + read-time diagnostics). It MUST validate + * all three runtime `view` shapes GENUINELY, where the bare container + * {@link ViewSchema} was a no-op (Zod strip-parsed ViewItem/personalization + * bodies to `{}`, so a broken `config` sailed through "valid"). + * + * 1. defineView aggregate container (non-empty) + * 2. standalone ViewItem record ({ name, object, viewKind, config }) + * 3. flattened personalization overlay (raw config + inherited identity, #2555) + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ViewMetadataSchema } from './view.zod'; + +const PLACEHOLDER_DATA = { provider: 'object', object: 'crm_lead' } as const; + +describe('ViewMetadataSchema — genuine validation across the three runtime shapes (#3095)', () => { + // ── shape 2: standalone ViewItem record ─────────────────────────────────── + describe('ViewItem record form', () => { + it('accepts a well-formed list ViewItem', () => { + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.all', + object: 'crm_lead', + viewKind: 'list', + label: 'All Leads', + config: { type: 'grid', columns: ['name'], data: PLACEHOLDER_DATA }, + }); + expect(r.success).toBe(true); + }); + + it('REJECTS a ViewItem whose kanban config is missing groupByField (was a no-op under ViewSchema)', () => { + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { + type: 'kanban', + columns: ['name'], + // groupByField is required by KanbanConfigSchema — omit it. + kanban: { summarizeField: 'amount', columns: ['name'] }, + }, + }); + expect(r.success).toBe(false); + }); + + it('accepts a well-formed form ViewItem', () => { + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.edit', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'simple', sections: [{ label: 'Details', fields: ['name'] }] }, + }); + expect(r.success).toBe(true); + }); + + it('REJECTS a form ViewItem carrying an invalid form `type` (config validated, not stripped)', () => { + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.edit', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'not_a_real_form_type' }, + }); + expect(r.success).toBe(false); + }); + }); + + // ── shape 1: defineView container ───────────────────────────────────────── + describe('defineView container form', () => { + it('accepts a non-empty container', () => { + const r = ViewMetadataSchema.safeParse({ + list: { type: 'grid', data: PLACEHOLDER_DATA, columns: [{ field: 'name' }] }, + }); + expect(r.success).toBe(true); + }); + + it('REJECTS a container whose nested list is missing required columns', () => { + const r = ViewMetadataSchema.safeParse({ + list: { type: 'grid', data: PLACEHOLDER_DATA }, + }); + expect(r.success).toBe(false); + }); + + it('REJECTS an explicitly-empty container (zero views — mirrors defineView)', () => { + // A body that names container slots but fills none of them registers no + // view; the container member's non-empty refine rejects it, and the + // flattened members reject it via their container-key guards. + expect(ViewMetadataSchema.safeParse({ listViews: {}, formViews: {} }).success).toBe(false); + expect(ViewMetadataSchema.safeParse({ listViews: {} }).success).toBe(false); + }); + + it('accepts a bare `{}` (legacy-compatible — the old ViewSchema also accepted it)', () => { + // Not a regression: a truly empty body carries no viewKind/object, so + // every consumer that filters on identity drops it. Pinned so the lenient + // flattened-overlay branch behaviour is intentional, not accidental. + expect(ViewMetadataSchema.safeParse({}).success).toBe(true); + }); + }); + + // ── shape 3: flattened personalization overlay (#2555) ──────────────────── + describe('flattened personalization overlay form', () => { + it('accepts a raw list config with identity inherited from the shadowed entry', () => { + // The exact shape normalizeViewMetadata persists on a console column-sort PUT. + const r = ViewMetadataSchema.safeParse({ + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title'], + sort: [{ id: '29200fa8-c416-471e-9ca3-913f9308ad89', field: 'estimate_hours', order: 'desc' }], + name: 'showcase_task.default', + viewKind: 'list', + object: 'showcase_task', + label: 'All Tasks', + }); + expect(r.success).toBe(true); + }); + + it('accepts a raw list config with NO identity (adhoc PUT, no registry entry to inherit from)', () => { + const r = ViewMetadataSchema.safeParse({ + type: 'grid', + data: { provider: 'object', object: 'showcase_task' }, + columns: ['title'], + sort: [{ field: 'estimate_hours', order: 'desc' }], + name: 'adhoc.view', + }); + expect(r.success).toBe(true); + }); + + it('accepts a raw form config overlay', () => { + const r = ViewMetadataSchema.safeParse({ + type: 'simple', + sections: [{ label: 'Details', fields: ['name'] }], + name: 'crm_lead.edit', + viewKind: 'form', + object: 'crm_lead', + }); + expect(r.success).toBe(true); + }); + + it('REJECTS a flattened list overlay whose kanban binding is broken (genuine, not stripped)', () => { + const r = ViewMetadataSchema.safeParse({ + type: 'kanban', + columns: ['name'], + kanban: { summarizeField: 'amount', columns: ['name'] }, // no groupByField + name: 'crm_lead.pipeline', + viewKind: 'list', + object: 'crm_lead', + }); + expect(r.success).toBe(false); + }); + + it('preserves auxiliary Studio round-trip keys without a strict-mode 422', () => { + // isPinned/sortOrder ride along on the overlay; the schema validates but + // must not reject unknown top-level aux keys (saveMetaItem persists verbatim). + const r = ViewMetadataSchema.safeParse({ + type: 'grid', + data: PLACEHOLDER_DATA, + columns: ['name'], + name: 'crm_lead.all', + viewKind: 'list', + object: 'crm_lead', + isPinned: true, + sortOrder: 3, + }); + expect(r.success).toBe(true); + }); + }); + + // ── mutual exclusion: a broken record/container is never rescued ────────── + describe('member exclusivity', () => { + it('does not rescue a broken record via the flattened branch (config guard)', () => { + // A record body carries a nested `config`; the flattened members pin + // `config` to undefined, so a broken config cannot be silently stripped. + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.pipeline', + object: 'crm_lead', + viewKind: 'list', + config: { type: 'grid', columns: 'not-an-array' }, + }); + expect(r.success).toBe(false); + }); + + it('does not rescue a broken container via the flattened branch (list guard)', () => { + const r = ViewMetadataSchema.safeParse({ + list: { type: 'grid', data: PLACEHOLDER_DATA }, // missing columns + name: 'crm_lead.default', + }); + expect(r.success).toBe(false); + }); + }); + + // ── JSON Schema emission (/api/v1/meta/types/view) ──────────────────────── + it('converts to a JSON Schema anyOf (union → anyOf) without throwing', () => { + const json = z.toJSONSchema(ViewMetadataSchema, { unrepresentable: 'any' }) as Record; + expect(Array.isArray(json.anyOf)).toBe(true); + expect((json.anyOf as unknown[]).length).toBe(4); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9911185fa5..58b47e168f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1232,6 +1232,114 @@ export function defineViewItem(config: z.input): ViewItem return ViewItemSchema.parse(config); } +// ─────────────────────────────────────────────────────────────────────────── +// Canonical persisted-`view`-body schema (the runtime metadata-type schema) +// ─────────────────────────────────────────────────────────────────────────── +// +// #3095 — the `view` metadata type maps to THIS schema in +// `metadata-type-schemas.ts` (both the save-time 422 validator and the +// read-time `computeMetadataDiagnostics` badge consult it). A single +// container schema ({@link ViewSchema}) is a NO-OP for two of the three shapes +// a `view` body actually carries at runtime: Zod strips unknown keys, so a +// standalone {@link ViewItemSchema} record ({ name, object, viewKind, config }) +// and a console personalization overlay (raw view config + inherited identity) +// both parse to `{}` — silently "valid" while their `config` is never checked. +// +// The three legitimate runtime shapes, each validated GENUINELY here: +// +// 1. `defineView` aggregate **container** — at least one of +// `list`/`form`/`listViews`/`formViews`. An empty container is rejected +// (it registers zero views; mirrors the {@link defineView} guard). +// 2. Standalone **ViewItem record** — `{ viewKind, config, … }`; the nested +// `config` is validated against ListView/FormView. +// 3. **Flattened runtime overlay** — a raw ListView/FormView config at the +// top level plus optional identity fields (`viewKind`/`object`/`label`, +// inherited from the shadowed registry entry by `normalizeViewMetadata`; +// see #2555). This is what a personalization PUT (column sort, inline +// edit, …) sends. Discriminated from a record by the ABSENCE of a nested +// `config`, and from a container by the ABSENCE of container slots — the +// structural guards below make those exclusions explicit so a malformed +// record/container can never be rescued by this lenient branch. +// +// Auxiliary Studio round-trip keys (`isPinned`, `sortOrder`, …) ride along on +// every shape: all four members strip-parse (no `.strict()`), so an unknown +// top-level key never 422s — matching the "persist the payload verbatim" +// contract in `saveMetaItem` (it validates but stores the original item). + +/** + * Optional identity + structural-guard fields layered onto the two "flattened + * runtime overlay" members. The `config`/`list`/`form`/`listViews`/`formViews` + * guards pin those keys to `undefined`: a body carrying any of them is a record + * or a container, not a flattened overlay, so it must be validated by the + * dedicated member instead of slipping through here with its real payload + * stripped away. + */ +function flattenedViewOverlayFields() { + return { + name: z.string().optional().describe('Save name / qualified view id (stamped by the write path).'), + object: z.string().optional().describe('Bound object name (inherited from the shadowed entry — #2555).'), + viewKind: ViewKindSchema.optional().describe('View family (inherited from the shadowed entry — #2555).'), + label: I18nLabelSchema.optional().describe('Display label (inherited from the shadowed entry — #2555).'), + isDefault: z.boolean().optional(), + order: z.number().int().optional(), + scope: ViewScopeSchema.optional(), + owner: z.string().optional(), + hidden: z.boolean().optional(), + protection: ProtectionSchema.optional(), + ...MetadataProtectionFields, + // Structural guards — a flattened overlay is neither a record nor a container. + config: z.undefined().optional(), + list: z.undefined().optional(), + form: z.undefined().optional(), + listViews: z.undefined().optional(), + formViews: z.undefined().optional(), + }; +} + +/** + * True when a `view` container defines at least one view. The bare + * {@link ViewSchema} accepts `{}` (every slot optional), which would let an + * empty overlay pass validation and register nothing — the same footgun + * {@link defineView} rejects at authoring time. + */ +function containerHasAView(v: unknown): boolean { + if (!v || typeof v !== 'object') return false; + const c = v as Record; + return Boolean( + c.list || + c.form || + (c.listViews && typeof c.listViews === 'object' && Object.keys(c.listViews).length > 0) || + (c.formViews && typeof c.formViews === 'object' && Object.keys(c.formViews).length > 0), + ); +} + +/** + * Canonical schema for ANY persisted `view` metadata body — the schema the + * `view` type registers in `metadata-type-schemas.ts`. A union over the three + * runtime shapes (see the block comment above), so save-time validation and + * read-time diagnostics finally see through `ViewItem`/personalization bodies + * instead of stripping them to `{}` (#3095). + * + * `z.toJSONSchema()` emits this as an `anyOf` of the four members, which the + * `/api/v1/meta/types/view` endpoint serves to Studio's SchemaForm. + */ +export const ViewMetadataSchema = lazySchema(() => + z.union([ + // 2. Standalone ViewItem record — nested config validated genuinely. + ViewItemSchema, + // 1. Non-empty defineView container. + ViewSchema.refine(containerHasAView, { + message: + 'A view container must define at least one of `list`, `form`, `listViews`, or `formViews`.', + }), + // 3. Flattened runtime overlay — inline ListView / FormView config + identity. + // The list member is tried first; a flattened form (no required + // `columns`, disjoint `type` enum) then matches the form member. + ListViewSchema.extend(flattenedViewOverlayFields()), + FormViewSchema.extend(flattenedViewOverlayFields()), + ]), +); + // ─────────────────────────────────────────────────────────────────────────── // defineView container → ViewItem expansion (shared by every loader) // @@ -1498,6 +1606,8 @@ export function defineForm( export type View = z.infer; export type ViewItem = z.infer; +/** Any persisted `view` metadata body: container | ViewItem record | flattened overlay (#3095). */ +export type ViewMetadata = z.infer; export type ViewScope = z.infer; export type ViewKind = z.infer; export type ListView = z.infer; From 62fbaed78d3c849e606efa0f7fd5f4f4bbe4bc46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:48:15 +0000 Subject: [PATCH 2/2] fix(spec): keep liveness gate + api-surface green for the `view` union schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI gates broke on the #3095 change that maps `view` to a union: - Spec property liveness: `check-liveness.mts` walked each metadata type's top-level OBJECT shape; a union has none, so it threw "view is not an object schema". Teach `shapeOf` to resolve a union to its first object-typed member — the canonical authorable container (list/form/listViews/formViews) the ledger governs. Discriminated-union members (ViewItem) are skipped; their inner config is the same ListView/FormView surface already governed under the container's list/form children. - API-surface snapshot: record the two intended new public exports (`ViewMetadata` type, `ViewMetadataSchema` const) via gen:api-surface. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LFmMsWLC3CVULh3Ke7rUdd --- packages/spec/api-surface.json | 2 ++ .../spec/scripts/liveness/check-liveness.mts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index dd8f38aa9d..8dcf44a72a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3471,6 +3471,8 @@ "ViewKeyCollision (interface)", "ViewKind (type)", "ViewKindSchema (const)", + "ViewMetadata (type)", + "ViewMetadataSchema (const)", "ViewSchema (const)", "ViewScope (type)", "ViewScopeSchema (const)", diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 291649d13a..e32c45652d 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -87,8 +87,22 @@ function unwrap(s: any, depth = 0): any { function shapeOf(s: any): Record | null { const u = unwrap(s); const def = defOf(u); - if (def?.type !== 'object') return null; - return def.shape ?? u.shape ?? null; + if (def?.type === 'object') return def.shape ?? u.shape ?? null; + // #3095 — a metadata type may register a UNION of shapes rather than a single + // object (e.g. `view`: defineView container | ViewItem record | flattened + // personalization overlay). The liveness ledger governs the canonical + // authorable **container**, which is the first object-typed member of the + // union. Discriminated-union members (ViewItem) are skipped — their inner + // `config` is the same ListView/FormView surface already governed under the + // container's `list` / `form` children — so this walks the container shape. + if (def?.type === 'union' && Array.isArray(def.options)) { + for (const opt of def.options) { + const uo = unwrap(opt); + const od = defOf(uo); + if (od?.type === 'object') return od.shape ?? uo.shape ?? null; + } + } + return null; } function descOf(s: any): string { let cur = s;