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
41 changes: 41 additions & 0 deletions .changeset/savemeta-422-union-branch-issues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): 元数据保存的 422 保留 union 分支处方,Studio 重新拿得到字段名 (#5364)

`saveMetaItem` 的 spec-conformance 检查在自己的注释里承诺 "structured Zod issues
so the Studio form can highlight the offending field"。顶层 `z.union` 让这句承诺
彻底落空:zod 把一个失败 union 的**所有**分支折叠成**一条**顶层 issue,`path` 是
空串、`message` 是字面量 `"Invalid input"`,而旧代码的 `parsed.error.issues.map(…)`
映射的正是这一条。

代价不是"文案不够好",而是**字段定位本身消失了**。`ViewMetadataSchema` 顶层就是一个
union(`view.zod.ts` 的 `z.preprocess(…, z.union([…]))`),所以**每一次** view 保存
失败都退化成:

```json
[{ "path": "", "message": "Invalid input", "code": "invalid_union" }]
```

一个字段名都没有到达作者,Studio 表单没有任何东西可以高亮;422 的摘要行也只是
`... failed spec validation: <root>: Invalid input`。被丢掉的分支里躺着的恰恰是
#4001 那批策展处方(点名真实键名的 unrecognized_keys)和带绝对路径、带合法枚举的
逐槽位判决。

现在这些分支被展开进 `issues[]`:union 自己那条**保留不动**(展开是严格叠加的,
今天读 `issues[0]` 的消费者不会少读到任何东西),后面跟上真正解释这次拒绝的分支,
路径按绝对路径拼好——分支 issue 的 `path` 是**相对于 union** 的,这是 #5014 付过
学费的坑。422 的 `message` 摘要行随之变得可读。

分支选择策略与已落地的两处**逐条一致**:丢弃只报根部 kind 不匹配的分支;报得最少
的分支胜出;`unrecognized_keys` 破平局;声明顺序决定其余;并列的全部输出(上限 3);
嵌套 union 递归展开(上限 3 层)。这是同一机制的**第三份**拷贝——`packages/spec`
的 `formatZodError`(#4971)只导出字符串渲染器,`packages/rest` 的
`zodIssuesToFields`(#5014)产出 ADR-0114 的 `{field, code}` 目录条目,而本处的信封
是 `{path, message, code}` 且 `code` 透传 zod 原码——形态不同,**判决必须相同**,
否则同一个错误会因为作者是从终端发布、还是 POST 数据 API、还是在 Studio 里保存,
拿到三套说法。

行为边界:合法的元数据照常保存,非法的元数据照常被 422 拒绝且不落库;变的只是
`issues[]` 从"一条无字段的 `Invalid input`"变成"那一条 + 真正解释它的分支"。
248 changes: 248 additions & 0 deletions packages/metadata-protocol/src/protocol.save-union-issues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5364 — `saveMetaItem`'s `422 INVALID_METADATA` keeps the union branch that
* explains the rejection.
*
* The 422's own comment promises "structured Zod issues so the Studio form can
* highlight the offending field". A top-level `z.union` broke that promise
* completely: zod folds every branch of a failed union into ONE issue whose
* path is `''` and whose message is the literal `"Invalid input"`, and the old
* `parsed.error.issues.map(…)` mapped exactly that. Since `ViewMetadataSchema`
* IS a top-level union (`view.zod.ts` — `z.preprocess(…, z.union([…]))`), EVERY
* failed `view` save arrived at Studio as one rootless line with no field name
* in it at all.
*
* This is the fourth consumer of one mechanism, and the verdict must match the
* other three by construction — `formatZodError` (#4971, spec),
* `zodIssuesToFields` (#5014, rest), `formatZodErrors` (#5341, cli). The tests
* below therefore pin the SHARED ranking's behaviour, not a locally-nicer one.
*
* Harness: the real repository write path over a stub engine, same shape as
* `protocol.save-flow-canonicalization.test.ts` — a fix INSIDE `saveMetaItem`
* cannot use a harness that mocks `saveMetaItem`.
*/
import { describe, expect, it } from 'vitest';
import { getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { ObjectStackProtocolImplementation, zodIssuesToMetadataIssues } from './protocol.js';

interface Row {
id: string;
type: string;
name: string;
organization_id: string | null;
state: string;
metadata: string;
}

const keyOf = (w: Record<string, unknown>) =>
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;

/** The engine surface the repository write path touches. */
function makeProtocol() {
const rows = new Map<string, Row>();
let nextId = 0;
const engine: any = {
async findOne() { return null; },
async find() { return []; },
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_audit') return { id: 'audit_skip' };
nextId += 1;
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
rows.set(keyOf(data), row);
return { id: row.id };
},
async update() { return { id: null }; },
async delete() { return { deleted: 0 }; },
registry: { registerItem: () => {}, registerObject: () => {} },
};
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
return { protocol, rows };
}

const save = (protocol: any, item: unknown, name = 'task_list', type = 'view') =>
protocol.saveMetaItem({ type, name, item });

async function rejection(promise: Promise<unknown>): Promise<any> {
try {
await promise;
} catch (err) {
return err;
}
throw new Error('expected the save to be rejected, but it resolved');
}

/** The issue's verbatim repro body: a list view whose `summary` has a typo'd key. */
const issueReproView = () => ({
name: 'task_list',
object: 'task',
type: 'list',
label: 'Tasks',
columns: [{ field: 'title', summary: { type: 'sum', fieldd: 'amount' } }],
});

describe('#5364 saveMetaItem 422 expands union branches', () => {
it('zod really does fold the whole rejection into one rootless issue (the defect, pinned)', () => {
// The "reverse verification" for this change, stated as a fact about
// zod rather than as a code revert: this is EXACTLY what the old
// `parsed.error.issues.map(…)` had to work with. Restore that map and
// every assertion in the next two tests goes red, because the branch
// payload below is the only place a field name exists.
const schema = getMetadataTypeSchema('view')!;
const parsed = (schema as any).safeParse(issueReproView());

expect(parsed.success).toBe(false);
expect(parsed.error.issues).toHaveLength(1);
expect(parsed.error.issues[0]).toMatchObject({ code: 'invalid_union', message: 'Invalid input' });
expect(parsed.error.issues[0].path).toEqual([]);
// …while four branches, each with a real reason, hang off `errors`.
expect(parsed.error.issues[0].errors.length).toBeGreaterThan(1);
});

it('the issue\'s view body: real key names now reach the author instead of "Invalid input"', async () => {
const { protocol, rows } = makeProtocol();

const err = await rejection(save(protocol, issueReproView()));

expect(err.code).toBe('INVALID_METADATA');
expect(err.status).toBe(422);
// Load-bearing: an invalid body is still refused, and still persists nothing.
expect(rows.size).toBe(0);

// The union's own entry is KEPT — the expansion is strictly additive, so
// no consumer reading `issues[0]` today loses what it reads.
expect(err.issues[0]).toEqual({ path: '', message: 'Invalid input', code: 'invalid_union' });
expect(err.issues.length).toBeGreaterThan(1);

// …and the branch that explains the rejection now rides along, carrying
// the #4001 curated prose WITH the offending key names in it.
const unknownKey = err.issues.find((i: any) => i.code === 'unrecognized_keys');
expect(unknownKey).toBeDefined();
expect(unknownKey.message).toContain('`type`');
expect(unknownKey.message).toContain('`columns`');

// The 422's summary line is readable now — before this it was the
// whole-message `…failed spec validation: <root>: Invalid input`.
expect(err.message).toContain('Unrecognized key(s)');
});

it('a container body localises the failure to a real path Studio can highlight', async () => {
// Ranking note (identical in all copies): the branch with the FEWEST
// issues wins, and `unrecognized_keys` breaks a tie. For a container
// body no branch reports an unknown key, so what survives is the
// per-slot verdict — an absolute path plus the legal enum.
const { protocol, rows } = makeProtocol();

const err = await rejection(save(protocol, { list: { type: 'nope', columns: [{ field: 'a' }] } }));

expect(err.status).toBe(422);
expect(rows.size).toBe(0);

const badType = err.issues.find((i: any) => i.path === 'list.type');
expect(badType).toBeDefined();
expect(badType.code).toBe('invalid_value');
expect(badType.message).toContain('"grid"');
// Absolute, not branch-relative: the branch raised this at `['list','type']`
// relative to the union sitting at the document root (#5014's trap).
expect(badType.path).toBe('list.type');
});

it('a spec-valid view still saves — the expansion never invents a rejection', async () => {
const { protocol, rows } = makeProtocol();

const result = await save(protocol, {
name: 'task_list', object: 'task', type: 'grid', label: 'Tasks',
columns: [{ field: 'title' }],
});

expect(result.success).toBe(true);
expect(rows.size).toBe(1);
});
});

describe('#5364 zodIssuesToMetadataIssues — the shared ranking, verbatim', () => {
const union = (errors: unknown[][], path: unknown[] = []) =>
({ code: 'invalid_union', message: 'Invalid input', path, errors });

it('a non-union issue passes through byte-identical', () => {
const issues = [{ code: 'invalid_type', message: 'Required', path: ['label'] }];
expect(zodIssuesToMetadataIssues(issues)).toEqual([
{ path: 'label', message: 'Required', code: 'invalid_type' },
]);
});

it('every branch a bare kind mismatch → output unchanged (no noise added)', () => {
// `z.union([z.string(), z.number()])` handed an object. Neither branch
// has a prescription; emitting both would be N× the noise for nothing.
const issues = [union([
[{ code: 'invalid_type', message: 'expected string', path: [] }],
[{ code: 'invalid_type', message: 'expected number', path: [] }],
], ['mode'])];
expect(zodIssuesToMetadataIssues(issues)).toEqual([
{ path: 'mode', message: 'Invalid input', code: 'invalid_union' },
]);
});

it('zod\'s "matched multiple" variant (errors: []) adds nothing', () => {
expect(zodIssuesToMetadataIssues([union([])])).toEqual([
{ path: '', message: 'Invalid input', code: 'invalid_union' },
]);
});

it('fewest issues wins; unrecognized_keys breaks the tie', () => {
const out = zodIssuesToMetadataIssues([union([
[{ code: 'invalid_value', message: 'wrong discriminator', path: ['kind'] }],
[{ code: 'unrecognized_keys', message: 'Unrecognized key(s): `nmae`', path: [] }],
[
{ code: 'invalid_value', message: 'wrong discriminator', path: ['kind'] },
{ code: 'invalid_type', message: 'Required', path: ['title'] },
],
])]);
expect(out).toEqual([
{ path: '', message: 'Invalid input', code: 'invalid_union' },
{ path: '', message: 'Unrecognized key(s): `nmae`', code: 'unrecognized_keys' },
]);
});

it('branches that tie at the top are all emitted, capped at three', () => {
const branch = (n: number) => [{ code: 'invalid_type', message: `bad ${n}`, path: [`f${n}`] }];
const out = zodIssuesToMetadataIssues([union([branch(1), branch(2), branch(3), branch(4)])]);
expect(out.map((i) => i.path)).toEqual(['', 'f1', 'f2', 'f3']);
});

it('branch paths are resolved against the union\'s own, at every level', () => {
const inner = union([[{ code: 'invalid_type', message: 'Required', path: ['id'] }]], ['nodes', 0]);
const out = zodIssuesToMetadataIssues([union([[inner]], ['flow'])]);
expect(out.map((i) => i.path)).toEqual(['flow', 'flow.nodes.0', 'flow.nodes.0.id']);
});

it('nesting is bounded at three levels — the fourth union is not expanded', () => {
const leaf = { code: 'invalid_type', message: 'Required', path: ['leaf'] };
const level4 = union([[leaf]], ['d']);
const level3 = union([[level4]], ['c']);
const level2 = union([[level3]], ['b']);
const level1 = union([[level2]], ['a']);
const out = zodIssuesToMetadataIssues([level1]);
// a → a.b → a.b.c → a.b.c.d, and there it stops: `leaf` never appears.
expect(out.map((i) => i.path)).toEqual(['a', 'a.b', 'a.b.c', 'a.b.c.d']);
expect(out.some((i) => i.path.endsWith('leaf'))).toBe(false);
});

it('two branches rejecting the same key with the same words say it once', () => {
const same = () => [{ code: 'unrecognized_keys', message: 'Unrecognized key(s): `nmae`', path: [] }];
const out = zodIssuesToMetadataIssues([union([same(), same()])]);
expect(out).toHaveLength(2);
expect(out[1]!.code).toBe('unrecognized_keys');
});

it('de-duplication is per top-level issue, never across two independent ones', () => {
const issue = { code: 'invalid_type', message: 'Required', path: ['label'] };
const out = zodIssuesToMetadataIssues([issue, issue]);
expect(out).toHaveLength(2);
});

it('a non-array `issues` yields an empty envelope rather than throwing', () => {
expect(zodIssuesToMetadataIssues(undefined)).toEqual([]);
expect(zodIssuesToMetadataIssues(null)).toEqual([]);
});
});
Loading
Loading