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
26 changes: 26 additions & 0 deletions .changeset/getuiview-slim-body.md
Original file line number Diff line number Diff line change
@@ -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 转红并点名该键。
18 changes: 15 additions & 3 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading