Skip to content

Commit dba7747

Browse files
qq9340100qq9340100claude
authored
fix(metadata-protocol): getUiView 不再多发三个未声明键,响应体与 GetUiViewResponseSchema 对齐 (#5948) (#6271)
GET /ui/view/:object/:type 由 getUiView 产出、REST 裸发(res.json,不套信封不校验)。 其声明是 GetUiViewResponseSchema(= ViewSchema),但实发 body 里的 list.object / form.object / form.label 三个键,ListViewSchema / FormViewSchema 这两个 strictObject 从未声明,实测 safeParse 判 unrecognized_keys 红。因为该 schema 全仓没有运行时读者, 这处分裂此前没有任何断言看得见。 - object 上移一层到容器(ViewSchema 一直在容器层声明它),成员层那份是冗余副本; - form.label(原 `Edit ${…}`)是渲染串而非元数据,直接摘除、不上移; - list.label 不动 —— ListViewSchema 正式声明了它。 packages/spec 零改动:本次是把实现修正到既有声明,而非改声明迁就实现。 常驻 pin 用生产端真实组装路径(实调 getUiView)喂 safeParse,而非手拼 fixture。 反向验证:恢复任一多发键 → pin 转红并点名该键。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: qq9340100 <sales@objectstack.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cca11e9 commit dba7747

3 files changed

Lines changed: 168 additions & 3 deletions

File tree

.changeset/getuiview-slim-body.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): `getUiView` 的响应体不再多发三个未声明键,与 `GetUiViewResponseSchema` 对齐
6+
7+
`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` 在全仓没有任何运行时读者,这处分裂此前没有任何断言看得见。
8+
9+
**FROM → TO**
10+
11+
```
12+
FROM { list: { type, object, label, columns, sort, searchableFields } }
13+
TO { object, list: { type, label, columns, sort, searchableFields } }
14+
15+
FROM { form: { type, object, label, sections } }
16+
TO { object, form: { type, sections } }
17+
```
18+
19+
- **迁移**:读 `object` 的消费者上移一层 —— `body.list.object` / `body.form.object` 改读 `body.object`。这是**相同的值换了层级**,不是删除:`ViewSchema` 一直在容器层声明 `object`(「Object this container binds to」),成员层那份本就是冗余副本。
20+
- `form.label`(原 `` `Edit ${…}` ``)**不上移、直接摘除**:它是渲染串而非元数据,任何 view schema 都没有声明过它;标题由 UI 自行拼(调用方本就知道自己请求的是哪个对象)。`list.label` **不受影响** —— `ListViewSchema` 正式声明了 `label`,保持原样。
21+
- 定级 **patch** 而非 minor/major:三键的消费面实测为零 —— `client-react``useView` 把 body 当 `any` 透传(`UseMetadataResult.data: any`),objectui 全仓 `meta.getView` 零命中(其 `getView(objectName, viewId)` 走的是 `client.meta.getItem('view', …)`,另一条通路)。无编译期破坏面,无类型改判。
22+
- `packages/spec` **零改动**:本次是把实现修正到既有声明,不是改声明迁就实现。
23+
24+
**未验面**:`cloud` 仓未在本次验证范围内(按 #5540 口径如实标注)。若该仓有直接读 `body.list.object` / `body.form.object` 的代码,需按上面的迁移上移一层;`form.label` 的读者需自行拼标题。
25+
26+
常驻 pin:`packages/metadata-protocol/src/protocol.ui-view-response-conformance.test.ts` —— 用**生产端真实组装路径**(实调 `getUiView`)喂 `GetUiViewResponseSchema.safeParse`,而非手拼 fixture。反向验证已跑:恢复任一多发键 → pin 转红并点名该键。

packages/metadata-protocol/src/protocol.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4201,9 +4201,17 @@ export class ObjectStackProtocolImplementation implements
42014201
// For now, just keep them roughly in order they appear in schema or priority list
42024202

42034203
return {
4204+
// [#5948] `object` sits on the CONTAINER, not on the view member.
4205+
// `ViewSchema` declares it here ("Object this container binds to")
4206+
// and `ListViewSchema` / `FormViewSchema` are `strictObject` that
4207+
// never declared it — so the old member-level copy made the real
4208+
// response fail its own declared schema with `unrecognized_keys`.
4209+
// Nothing read it (measured: `useView` passes the body through as
4210+
// `any`, objectui never calls `meta.getView`), so this is a
4211+
// relocation, not a removal: readers move up one level.
4212+
object: request.object,
42044213
list: {
42054214
type: 'grid' as const,
4206-
object: request.object,
42074215
label: schema.label || schema.name,
42084216
columns: columns.map(f => ({
42094217
field: f,
@@ -4237,10 +4245,14 @@ export class ObjectStackProtocolImplementation implements
42374245
}));
42384246

42394247
return {
4248+
// [#5948] Same relocation as the list branch above. The dropped
4249+
// `label` is NOT relocated: it was `Edit ${…}` — a rendered UI
4250+
// string, not metadata, and `FormViewSchema` deliberately has no
4251+
// `label`. The caller already knows the object it asked for, so
4252+
// the heading is the UI's to compose.
4253+
object: request.object,
42404254
form: {
42414255
type: 'simple' as const,
4242-
object: request.object,
4243-
label: `Edit ${schema.label || schema.name}`,
42444256
sections: [
42454257
{
42464258
label: 'General Information',
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#5948] `getUiView` is the producer behind `GET /ui/view/:object/:type`
4+
// (`packages/rest/src/rest-server.ts`, which does a bare `res.json(view)` —
5+
// no envelope, no validation). Its declared response schema is
6+
// `GetUiViewResponseSchema` (`packages/spec/src/api/protocol.zod.ts`), which
7+
// resolves to `ViewSchema`.
8+
//
9+
// Until this fix the two shapes disagreed, and nothing in the repo could see
10+
// it: `GetUiViewResponseSchema` had no runtime reader anywhere, so the body
11+
// went out unchecked. Measured on `origin/main` before the change:
12+
//
13+
// real list body -> RED [unrecognized_keys] path=["list"] … `object`
14+
// real form body -> RED [unrecognized_keys] path=["form"] … `object`, `label`
15+
//
16+
// `ListViewSchema` / `FormViewSchema` are `strictObject` and never declared
17+
// `object`; the container (`ViewSchema`) is where `object` belongs and always
18+
// declared it. `form.label` was `Edit ${…}` — a rendered UI string that no
19+
// view schema declares at all.
20+
//
21+
// These tests are the standing guard on that agreement. They deliberately
22+
// parse the output of the REAL `getUiView` call rather than a hand-built
23+
// literal: a hand-written fixture would pin what this file believes the
24+
// producer emits, which is exactly the belief that was wrong before. Feeding
25+
// the production assembly path through the production schema is the only
26+
// version of this test that can fail when the producer drifts.
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { GetUiViewResponseSchema } from '@objectstack/spec/api';
30+
import { ObjectStackProtocolImplementation } from './protocol.js';
31+
32+
const SCHEMA = {
33+
name: 'account',
34+
label: 'Account',
35+
fields: {
36+
id: { name: 'id', type: 'text' },
37+
name: { name: 'name', type: 'text', label: 'Name', required: true },
38+
status: { name: 'status', type: 'text', label: 'Status' },
39+
notes: { name: 'notes', type: 'textarea', label: 'Notes' },
40+
secret: { name: 'secret', type: 'text', hidden: true },
41+
created_at: { name: 'created_at', type: 'datetime' },
42+
},
43+
};
44+
45+
function protocolFor(schema: unknown = SCHEMA) {
46+
const engine = { registry: { getObject: () => schema } };
47+
return new ObjectStackProtocolImplementation(engine as any);
48+
}
49+
50+
/** Render zod issues into something a failure message can be read from. */
51+
function explain(result: { success: boolean; error?: any }) {
52+
if (result.success) return 'GREEN';
53+
return result.error.issues
54+
.map((i: any) => `[${i.code}] path=${JSON.stringify(i.path)} ${i.message}`)
55+
.join('\n');
56+
}
57+
58+
describe('[#5948] getUiView emits a body that satisfies its own declared schema', () => {
59+
it('list branch parses GREEN against GetUiViewResponseSchema', async () => {
60+
const p = protocolFor();
61+
const body = await p.getUiView({ object: 'account', type: 'list' });
62+
63+
const parsed = GetUiViewResponseSchema.safeParse(body);
64+
expect(explain(parsed)).toBe('GREEN');
65+
expect(parsed.success).toBe(true);
66+
});
67+
68+
it('form branch parses GREEN against GetUiViewResponseSchema', async () => {
69+
const p = protocolFor();
70+
const body = await p.getUiView({ object: 'account', type: 'form' });
71+
72+
const parsed = GetUiViewResponseSchema.safeParse(body);
73+
expect(explain(parsed)).toBe('GREEN');
74+
expect(parsed.success).toBe(true);
75+
});
76+
77+
// The three keys this issue removed, pinned by name. The GREEN assertions
78+
// above already fail if any of them comes back — `strictObject` rejects
79+
// them — but naming them here is what makes a future failure legible
80+
// instead of a bare "unrecognized_keys" the next reader has to decode.
81+
it('the object binding sits on the container, never on the view member', async () => {
82+
const p = protocolFor();
83+
84+
const listBody: any = await p.getUiView({ object: 'account', type: 'list' });
85+
expect(listBody.object).toBe('account');
86+
expect(listBody.list).toBeDefined();
87+
expect(listBody.list).not.toHaveProperty('object');
88+
89+
const formBody: any = await p.getUiView({ object: 'account', type: 'form' });
90+
expect(formBody.object).toBe('account');
91+
expect(formBody.form).toBeDefined();
92+
expect(formBody.form).not.toHaveProperty('object');
93+
});
94+
95+
it('the form view carries no rendered `label` heading', async () => {
96+
const p = protocolFor();
97+
const formBody: any = await p.getUiView({ object: 'account', type: 'form' });
98+
// `Edit ${schema.label}` was a UI string living in a metadata body.
99+
// `FormViewSchema` declares no `label`; the heading is the UI's to compose.
100+
expect(formBody.form).not.toHaveProperty('label');
101+
});
102+
103+
// Guards the half of the payload that did NOT move: `ListViewSchema` DOES
104+
// declare `label`, so the list view keeps its own. A future cleanup that
105+
// over-reaches and strips this one too would be caught here rather than
106+
// silently degrading the list header.
107+
it('the list view keeps its declared `label`', async () => {
108+
const p = protocolFor();
109+
const listBody: any = await p.getUiView({ object: 'account', type: 'list' });
110+
expect(listBody.list.label).toBe('Account');
111+
});
112+
113+
// The producer builds `sort` only when the object has `created_at`. The
114+
// no-`created_at` branch emits `sort: undefined`, which is a different
115+
// parse path (optional vs present-but-undefined) and was never exercised.
116+
it('parses GREEN for an object with no created_at (sort omitted)', async () => {
117+
const p = protocolFor({
118+
name: 'tag',
119+
label: 'Tag',
120+
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
121+
});
122+
123+
const listBody = await p.getUiView({ object: 'tag', type: 'list' });
124+
const parsed = GetUiViewResponseSchema.safeParse(listBody);
125+
expect(explain(parsed)).toBe('GREEN');
126+
});
127+
});

0 commit comments

Comments
 (0)