diff --git a/.changeset/getuiview-slim-body.md b/.changeset/getuiview-slim-body.md new file mode 100644 index 0000000000..06cadcf020 --- /dev/null +++ b/.changeset/getuiview-slim-body.md @@ -0,0 +1,26 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): `getUiView` 的响应体不再多发三个未声明键,与 `GetUiViewResponseSchema` 对齐 + +`GET /ui/view/:object/:type` 由 `getUiView` 产出、REST 层 `res.json(view)` 裸发(不套信封、不校验)。它的声明是 `GetUiViewResponseSchema`(= `ViewSchema`),但实发 body 里的 `list.object` / `form.object` / `form.label` 三个键,`ListViewSchema` / `FormViewSchema` 这两个 `strictObject` 从未声明,实测 `safeParse` 直接 `unrecognized_keys` 判红。因为 `GetUiViewResponseSchema` 在全仓没有任何运行时读者,这处分裂此前没有任何断言看得见。 + +**FROM → TO** + +``` +FROM { list: { type, object, label, columns, sort, searchableFields } } +TO { object, list: { type, label, columns, sort, searchableFields } } + +FROM { form: { type, object, label, sections } } +TO { object, form: { type, sections } } +``` + +- **迁移**:读 `object` 的消费者上移一层 —— `body.list.object` / `body.form.object` 改读 `body.object`。这是**相同的值换了层级**,不是删除:`ViewSchema` 一直在容器层声明 `object`(「Object this container binds to」),成员层那份本就是冗余副本。 +- `form.label`(原 `` `Edit ${…}` ``)**不上移、直接摘除**:它是渲染串而非元数据,任何 view schema 都没有声明过它;标题由 UI 自行拼(调用方本就知道自己请求的是哪个对象)。`list.label` **不受影响** —— `ListViewSchema` 正式声明了 `label`,保持原样。 +- 定级 **patch** 而非 minor/major:三键的消费面实测为零 —— `client-react` 的 `useView` 把 body 当 `any` 透传(`UseMetadataResult.data: any`),objectui 全仓 `meta.getView` 零命中(其 `getView(objectName, viewId)` 走的是 `client.meta.getItem('view', …)`,另一条通路)。无编译期破坏面,无类型改判。 +- `packages/spec` **零改动**:本次是把实现修正到既有声明,不是改声明迁就实现。 + +**未验面**:`cloud` 仓未在本次验证范围内(按 #5540 口径如实标注)。若该仓有直接读 `body.list.object` / `body.form.object` 的代码,需按上面的迁移上移一层;`form.label` 的读者需自行拼标题。 + +常驻 pin:`packages/metadata-protocol/src/protocol.ui-view-response-conformance.test.ts` —— 用**生产端真实组装路径**(实调 `getUiView`)喂 `GetUiViewResponseSchema.safeParse`,而非手拼 fixture。反向验证已跑:恢复任一多发键 → pin 转红并点名该键。 diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 71404b01d7..66852738a4 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4201,9 +4201,17 @@ export class ObjectStackProtocolImplementation implements // For now, just keep them roughly in order they appear in schema or priority list return { + // [#5948] `object` sits on the CONTAINER, not on the view member. + // `ViewSchema` declares it here ("Object this container binds to") + // and `ListViewSchema` / `FormViewSchema` are `strictObject` that + // never declared it — so the old member-level copy made the real + // response fail its own declared schema with `unrecognized_keys`. + // Nothing read it (measured: `useView` passes the body through as + // `any`, objectui never calls `meta.getView`), so this is a + // relocation, not a removal: readers move up one level. + object: request.object, list: { type: 'grid' as const, - object: request.object, label: schema.label || schema.name, columns: columns.map(f => ({ field: f, @@ -4237,10 +4245,14 @@ export class ObjectStackProtocolImplementation implements })); return { + // [#5948] Same relocation as the list branch above. The dropped + // `label` is NOT relocated: it was `Edit ${…}` — a rendered UI + // string, not metadata, and `FormViewSchema` deliberately has no + // `label`. The caller already knows the object it asked for, so + // the heading is the UI's to compose. + object: request.object, form: { type: 'simple' as const, - object: request.object, - label: `Edit ${schema.label || schema.name}`, sections: [ { label: 'General Information', diff --git a/packages/metadata-protocol/src/protocol.ui-view-response-conformance.test.ts b/packages/metadata-protocol/src/protocol.ui-view-response-conformance.test.ts new file mode 100644 index 0000000000..5faf80579d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.ui-view-response-conformance.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5948] `getUiView` is the producer behind `GET /ui/view/:object/:type` +// (`packages/rest/src/rest-server.ts`, which does a bare `res.json(view)` — +// no envelope, no validation). Its declared response schema is +// `GetUiViewResponseSchema` (`packages/spec/src/api/protocol.zod.ts`), which +// resolves to `ViewSchema`. +// +// Until this fix the two shapes disagreed, and nothing in the repo could see +// it: `GetUiViewResponseSchema` had no runtime reader anywhere, so the body +// went out unchecked. Measured on `origin/main` before the change: +// +// real list body -> RED [unrecognized_keys] path=["list"] … `object` +// real form body -> RED [unrecognized_keys] path=["form"] … `object`, `label` +// +// `ListViewSchema` / `FormViewSchema` are `strictObject` and never declared +// `object`; the container (`ViewSchema`) is where `object` belongs and always +// declared it. `form.label` was `Edit ${…}` — a rendered UI string that no +// view schema declares at all. +// +// These tests are the standing guard on that agreement. They deliberately +// parse the output of the REAL `getUiView` call rather than a hand-built +// literal: a hand-written fixture would pin what this file believes the +// producer emits, which is exactly the belief that was wrong before. Feeding +// the production assembly path through the production schema is the only +// version of this test that can fail when the producer drifts. + +import { describe, it, expect } from 'vitest'; +import { GetUiViewResponseSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const SCHEMA = { + name: 'account', + label: 'Account', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name', required: true }, + status: { name: 'status', type: 'text', label: 'Status' }, + notes: { name: 'notes', type: 'textarea', label: 'Notes' }, + secret: { name: 'secret', type: 'text', hidden: true }, + created_at: { name: 'created_at', type: 'datetime' }, + }, +}; + +function protocolFor(schema: unknown = SCHEMA) { + const engine = { registry: { getObject: () => schema } }; + return new ObjectStackProtocolImplementation(engine as any); +} + +/** Render zod issues into something a failure message can be read from. */ +function explain(result: { success: boolean; error?: any }) { + if (result.success) return 'GREEN'; + return result.error.issues + .map((i: any) => `[${i.code}] path=${JSON.stringify(i.path)} ${i.message}`) + .join('\n'); +} + +describe('[#5948] getUiView emits a body that satisfies its own declared schema', () => { + it('list branch parses GREEN against GetUiViewResponseSchema', async () => { + const p = protocolFor(); + const body = await p.getUiView({ object: 'account', type: 'list' }); + + const parsed = GetUiViewResponseSchema.safeParse(body); + expect(explain(parsed)).toBe('GREEN'); + expect(parsed.success).toBe(true); + }); + + it('form branch parses GREEN against GetUiViewResponseSchema', async () => { + const p = protocolFor(); + const body = await p.getUiView({ object: 'account', type: 'form' }); + + const parsed = GetUiViewResponseSchema.safeParse(body); + expect(explain(parsed)).toBe('GREEN'); + expect(parsed.success).toBe(true); + }); + + // The three keys this issue removed, pinned by name. The GREEN assertions + // above already fail if any of them comes back — `strictObject` rejects + // them — but naming them here is what makes a future failure legible + // instead of a bare "unrecognized_keys" the next reader has to decode. + it('the object binding sits on the container, never on the view member', async () => { + const p = protocolFor(); + + const listBody: any = await p.getUiView({ object: 'account', type: 'list' }); + expect(listBody.object).toBe('account'); + expect(listBody.list).toBeDefined(); + expect(listBody.list).not.toHaveProperty('object'); + + const formBody: any = await p.getUiView({ object: 'account', type: 'form' }); + expect(formBody.object).toBe('account'); + expect(formBody.form).toBeDefined(); + expect(formBody.form).not.toHaveProperty('object'); + }); + + it('the form view carries no rendered `label` heading', async () => { + const p = protocolFor(); + const formBody: any = await p.getUiView({ object: 'account', type: 'form' }); + // `Edit ${schema.label}` was a UI string living in a metadata body. + // `FormViewSchema` declares no `label`; the heading is the UI's to compose. + expect(formBody.form).not.toHaveProperty('label'); + }); + + // Guards the half of the payload that did NOT move: `ListViewSchema` DOES + // declare `label`, so the list view keeps its own. A future cleanup that + // over-reaches and strips this one too would be caught here rather than + // silently degrading the list header. + it('the list view keeps its declared `label`', async () => { + const p = protocolFor(); + const listBody: any = await p.getUiView({ object: 'account', type: 'list' }); + expect(listBody.list.label).toBe('Account'); + }); + + // The producer builds `sort` only when the object has `created_at`. The + // no-`created_at` branch emits `sort: undefined`, which is a different + // parse path (optional vs present-but-undefined) and was never exercised. + it('parses GREEN for an object with no created_at (sort omitted)', async () => { + const p = protocolFor({ + name: 'tag', + label: 'Tag', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + }); + + const listBody = await p.getUiView({ object: 'tag', type: 'list' }); + const parsed = GetUiViewResponseSchema.safeParse(listBody); + expect(explain(parsed)).toBe('GREEN'); + }); +});