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
60 changes: 60 additions & 0 deletions .changeset/view-union-and-container-issue-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": minor
"@objectstack/rest": patch
"@objectstack/cli": patch
---

fix(spec,rest,cli): validation diagnostics reach the real defect — named view-union branches, and `invalid_key` / `invalid_element` descent (#6391, #5389)

Two cases where a refusal fired correctly but its *diagnostic* could not reach the
element that actually failed. Both fixes change the DIAGNOSTIC face only: every
input that parsed before parses after, every input refused before is refused
after, and each refusal keeps its issue codes (ADR-0112 / #6142 — a better
diagnostic never weakens the envelope). Pinned in both directions.

**#6391 — `ViewMetadataSchema`'s union members are now contractual.** Three of
its four members were inline expressions with no name, so a consumer diagnosing a
failure could only reach a branch by indexing the nested `invalid_union`
`errors[]` **by member position**; objectui shipped exactly that and had to hold
the coupling down with a canary test (objectui#3606 / PR objectui#3624). The
union is now built from a named record:

- `VIEW_METADATA_BRANCHES` — the branch names, in the union's own order;
- `VIEW_METADATA_MEMBERS` — branch name → the schema the union actually holds
(`viewItem` is identically `ViewItemWireSchema`, as before);
- `selectViewMetadataBranch(body)` — which branch a body claims, by the
discriminants the members already declare;
- `diagnoseViewMetadata(body)` — the failing branch **named**, with that branch's
own leaf issues and real field paths, so no consumer needs `errors[i]`.

The union is **not** converted to `z.discriminatedUnion`. That would move the
acceptance face — a discriminated union refuses an unknown discriminant outright
where this one falls through all four members, and several of these shapes carry
no discriminant at all. `ViewMetadataSchema` remains the only judge of
acceptance; the dispatch only explains a verdict it did not make, and a pin
asserts the two never disagree.

**#5389 — `invalid_key` / `invalid_element` are descended, in all three
consumers.** Zod hangs a failing record-key / map-element schema's real issues on
`issue.issues` — the same shape as `invalid_union`'s `issue.errors`, one property
name over. The family had already been fixed three times for `errors` (#4971,
#5014, #5341) and none of the three consumers read `issues`, so both codes
surfaced as a bare wrapper line with the prescription stranded in the payload.
Now `formatZodError`/`formatZodIssue` (spec), `zodIssuesToFields` (the REST wire)
and `formatZodErrors` (the CLI terminal) all descend it.

Before / after, a `z.record` with a constrained key:

```
✗ fields.First Name: Invalid key in record
```
```
✗ fields.First Name: Invalid key in record
✗ fields.First Name: Invalid identifier. Must be lowercase snake_case …
```

The expansion is strictly additive on every surface: the container's own line
(and, on the wire, its own `{field, code: 'invalid_shape', message}` entry) is
unchanged, and the leaves follow it. Unlike a union's branches — competing
candidates, therefore ranked and capped — a container's `issues` are the one list
the inner schema produced, so every one of them is reported.
52 changes: 39 additions & 13 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,18 +208,37 @@ export function createTimer() {
// ─── Zod Error Formatting ───────────────────────────────────────────

/**
* How far the branch lines of an expanded union are pushed to sit UNDER the
* `code: message` line this file prints for the union itself.
* How far the nested lines of an expanded issue are pushed to sit UNDER the
* `code: message` line this file prints for the issue itself.
*
* `formatZodIssue` indents its own depth-0 line by 2 spaces and each nested
* level by 2 more; this file's per-issue block is at 4/6. Adding 4 puts the
* first branch level at 8 — one step below the `invalid_union: Invalid input`
* first nested level at 8 — one step below the `invalid_union: Invalid input`
* line it explains — and keeps every deeper level nested relative to it.
*/
const UNION_BRANCH_REINDENT = ' ';
const NESTED_ISSUE_REINDENT = ' ';

/**
* The lines that explain an `invalid_union`, or nothing at all.
* [#5389] The issue codes whose real diagnosis is nested one level down, and
* which `formatZodIssue` therefore renders as more than one line.
*
* `invalid_union` puts each candidate branch on `issue.errors[]`; `invalid_key`
* / `invalid_element` put the key/element schema's own issues on
* `issue.issues[]`. Same defect, two property names — and the gate below has to
* name both, or the terminal keeps printing `invalid_key: Invalid key in
* record` with the prescription stranded in the payload.
*
* Kept in step with `CONTAINER_ISSUE_CODES` in `@objectstack/spec`'s
* `error-map.zod.ts`, which is where the descent itself lives.
*/
const EXPANDABLE_ISSUE_CODES: ReadonlySet<string> = new Set([
'invalid_union',
'invalid_key',
'invalid_element',
]);

/**
* The lines that explain an expandable issue, or nothing at all.
*
* Zod folds every branch of a failed union into ONE issue whose own `message`
* is the literal `"Invalid input"`; each branch's real rejection sits in
Expand All @@ -241,18 +260,24 @@ const UNION_BRANCH_REINDENT = ' ';
* so had to re-implement the ranking — the terminal needs exactly the STRING
* that spec already exports, so here the reuse is a plain import.
*
* Line 0 of that render is the union's own verdict, which the caller has
* [#5389] The same import now also covers `invalid_key` / `invalid_element`,
* whose diagnosis hangs on `issue.issues[]` instead. Widening the gate is the
* WHOLE of this file's share of that fix — the descent is spec's, so the
* terminal inherits it the moment it stops refusing to ask.
*
* Line 0 of that render is the issue's own verdict, which the caller has
* already printed in this file's own idiom; only the explanation is returned,
* so the change is strictly ADDITIVE — nothing that printed before #5341 stops
* printing. A non-union issue renders as a single line, hence never reaches
* here and could not add one anyway.
* printing. An issue with nothing nested renders as a single line, hence never
* reaches here and could not add one anyway.
*/
function unionBranchLines(issue: unknown): string[] {
if ((issue as { code?: unknown } | null)?.code !== 'invalid_union') return [];
function nestedIssueLines(issue: unknown): string[] {
const code = (issue as { code?: unknown } | null)?.code;
if (typeof code !== 'string' || !EXPANDABLE_ISSUE_CODES.has(code)) return [];
return formatZodIssue(issue as Parameters<typeof formatZodIssue>[0])
.split('\n')
.slice(1)
.map((line) => `${UNION_BRANCH_REINDENT}${line}`);
.map((line) => `${NESTED_ISSUE_REINDENT}${line}`);
}

export function formatZodErrors(error: ZodError) {
Expand Down Expand Up @@ -291,8 +316,9 @@ export function formatZodErrors(error: ZodError) {
console.log(chalk.dim(` received: ${chalk.red((issue as any).received)}`));
}

// [#5341] …and, for a union, the branch that actually explains it.
for (const line of unionBranchLines(issue)) {
// [#5341] …and, for a union — [#5389] or a record key / map element —
// the nested issues that actually explain it.
for (const line of nestedIssueLines(issue)) {
console.log(chalk.dim(line));
}
}
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/format-zod-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,42 @@ describe('[#5341] `os validate` delivers a union branch prescription', () => {
expect(JSON.stringify(payload.errors[0].errors)).toContain('`direction` → `order`');
}, 120_000);
});

/**
* [#5389] The terminal's share of the 4th/5th family members.
*
* `invalid_key` / `invalid_element` hang the key/element schema's real
* complaint on `issue.issues[]` rather than on `invalid_union`'s
* `issue.errors[]`. The descent itself is `@objectstack/spec`'s — reused, not
* re-derived, exactly as #5341 reused it for the union — so this file's whole
* share of the fix is that its gate stopped saying `code !== 'invalid_union'`
* and started naming all three expandable codes.
*
* Strictly additive, same as #5341: the `code: message` line the terminal
* already printed for the container is untouched; only the explanation is new.
*/
describe('[#5389] formatZodErrors expands invalid_key / invalid_element', () => {
const SnakeKey = z
.string()
.regex(/^[a-z][a-z0-9_]*$/, "Invalid identifier. Must be lowercase snake_case (e.g. 'first_name').");

it('prints the key schema prose under the container line', () => {
const schema = z.object({ fields: z.record(SnakeKey, z.object({ type: z.string() })) });
const out = render(schema.safeParse({ fields: { 'First Name': { type: 'text' } } }).error!);
expect(out).toContain('invalid_key: Invalid key in record');
expect(out).toContain("Invalid identifier. Must be lowercase snake_case (e.g. 'first_name').");
});

it('prints the element schema prose for a map', () => {
const schema = z.map(z.object({ id: z.string() }), z.number().min(5, 'too small'));
const out = render(schema.safeParse(new Map([[{ id: 'a' }, 1]])).error!);
expect(out).toContain('invalid_element');
expect(out).toContain('too small');
});

it('adds nothing to an ordinary issue', () => {
// The conservative half: only the three expandable codes may grow lines.
const before = render(z.object({ a: z.string() }).safeParse({}).error!);
expect(before.split('\n').filter((l) => l.includes('✗'))).toHaveLength(1);
});
});
46 changes: 39 additions & 7 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ function valueAtPath(input: unknown, path: unknown): unknown {
}

/**
* How many levels of nested `invalid_union` are expanded below a top-level
* issue, and how many equally-informative branches are emitted at one level.
* How many levels of nested issues are expanded below a top-level issue, and
* how many equally-informative union branches are emitted at one level.
*
* Both bounds — and the whole selection policy below — are the ones
* `formatZodError` landed for the CLI/spec side of this defect (#4971,
Expand All @@ -223,9 +223,22 @@ function valueAtPath(input: unknown, path: unknown): unknown {
* the same, or one mistake gets two different prescriptions depending on whether
* the author published from the terminal or POSTed to the API (#5014).
*/
const UNION_EXPANSION_DEPTH_LIMIT = 3;
const NESTED_EXPANSION_DEPTH_LIMIT = 3;
const UNION_BRANCH_EMIT_LIMIT = 3;

/**
* [#5389] The issue codes that hang their real diagnosis on `issue.issues`
* rather than on `invalid_union`'s `issue.errors`.
*
* `invalid_key` is raised when `z.record(K, V)`'s KEY schema rejects a key (and
* by `z.map` for a non-`PropertyKey` key); `invalid_element` when `z.map`'s
* VALUE schema rejects the value under such a key. Both carry a bare wrapper
* message ("Invalid key in record") with everything the client needs one level
* down — the same defect as #5014, one property name over. Kept in step with
* `CONTAINER_ISSUE_CODES` in `spec/src/shared/error-map.zod.ts`.
*/
const CONTAINER_ISSUE_CODES: ReadonlySet<string> = new Set(['invalid_key', 'invalid_element']);

/** A Zod issue path, normalised to the array Zod always produces. */
function issuePathOf(issue: any): Array<string | number> {
return Array.isArray(issue?.path) ? issue.path : [];
Expand Down Expand Up @@ -308,6 +321,14 @@ function selectUnionBranches(branches: readonly (readonly any[])[]): readonly (r
* the branches that explain it, with `field` resolved against the union's own
* path — branch paths are relative to it.
*
* [#5389] An `invalid_key` / `invalid_element` behaves the same way one property
* name over: its own entry (zod's `"Invalid key in record"`, also
* `invalid_shape`) followed by the entries on `issue.issues`, whose paths are
* likewise relative. The one difference from a union: those issues are not
* competing candidates, so they are NOT ranked or capped — every one of them is
* a true statement about the value, and dropping any would be dropping a real
* diagnosis rather than declining to guess.
*
* The union's entry is kept rather than replaced: it is the only entry naming
* the slot the client sent, existing clients already read it, and when every
* branch is uninformative it is still the whole answer. So the expansion is
Expand Down Expand Up @@ -343,7 +364,11 @@ function collectIssueFields(
const branches: readonly (readonly any[])[] = issue?.code === 'invalid_union' && Array.isArray(issue?.errors)
? issue.errors.filter((branch: unknown): branch is any[] => Array.isArray(branch))
: [];
const expandable = branches.length > 0 && depth < UNION_EXPANSION_DEPTH_LIMIT;
const contained: readonly any[] = CONTAINER_ISSUE_CODES.has(issue?.code) && Array.isArray(issue?.issues)
? issue.issues
: [];
const expandable = (branches.length > 0 || contained.length > 0)
&& depth < NESTED_EXPANSION_DEPTH_LIMIT;

const entry = {
field,
Expand All @@ -361,10 +386,17 @@ function collectIssueFields(
out.push(entry);
if (!expandable) return;

for (const branch of selectUnionBranches(branches)) {
for (const nested of branch) {
collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out);
if (branches.length > 0) {
for (const branch of selectUnionBranches(branches)) {
for (const nested of branch) {
collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out);
}
}
return;
}

for (const nested of contained) {
collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out);
}
}

Expand Down
82 changes: 82 additions & 0 deletions packages/rest/src/zod-union-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,85 @@ describe('zodIssuesToFields — invalid_union expansion (#5014)', () => {
});
});
});

/**
* [#5389] `invalid_key` / `invalid_element` — the same defect, one property
* name over, on the same wire.
*
* `invalid_union` buries a branch's real complaint in `issue.errors[]`; these
* two bury the key/element schema's real complaint in `issue.issues[]`. Before
* this, a client POSTing a record with a bad KEY got exactly one `fields[]`
* entry — `{field: 'fields.First Name', code: 'invalid_shape', message:
* 'Invalid key in record'}` — with the sentence naming the rule produced and
* dropped, which is #5014 verbatim.
*
* The expansion is strictly ADDITIVE, same contract as #5014: the container's
* own entry stays (it is the only one naming the slot, and existing clients
* read it) and the leaf entries follow it. ADR-0114's `{field, code, message}`
* shape is unchanged, and the container entry keeps mapping to `invalid_shape`.
*
* Every fixture drives a REAL `safeParse`, for the reason the file's header
* gives: `issue.issues` is Zod's internal shape.
*/
describe('zodIssuesToFields — invalid_key / invalid_element descent (#5389)', () => {
const fieldsFor = (schema: z.ZodType, value: unknown) => {
const r = schema.safeParse(value);
expect(r.success, 'the fixture must actually fail to parse').toBe(false);
return zodIssuesToFields((r as { error: { issues: unknown[] } }).error.issues, value);
};

const SnakeKey = z
.string()
.regex(/^[a-z][a-z0-9_]*$/, "Invalid identifier. Must be lowercase snake_case (e.g. 'first_name').");

it('delivers the KEY schema prescription beside the container entry', () => {
const schema = z.object({ fields: z.record(SnakeKey, z.object({ type: z.string() })) });
const fields = fieldsFor(schema, { fields: { 'First Name': { type: 'text' } } });

expect(fields).toEqual([
// Unchanged — the entry clients read today.
{ field: 'fields.First Name', code: 'invalid_shape', message: 'Invalid key in record' },
// …and the leaf that says WHY, with the container's path resolved.
{
field: 'fields.First Name',
code: 'invalid_format',
message: "Invalid identifier. Must be lowercase snake_case (e.g. 'first_name').",
},
]);
});

it('emits EVERY nested issue — a container has no branches to rank', () => {
// Deliberate divergence from the union descent: `errors[]` holds
// competing candidates, so it is ranked and capped; `issues[]` is the
// one list the element schema produced, and each entry is a real field.
const schema = z.map(
z.object({ id: z.string() }),
z.object({ a: z.string(), b: z.string(), c: z.string() }),
);
const fields = fieldsFor(schema, new Map([[{ id: 'x' }, {}]]));
expect(fields[0]).toMatchObject({ code: 'invalid_shape' });
expect(fields.map((f) => f.field)).toEqual(['', 'a', 'b', 'c']);
});

it('keeps the catalog code an ADR-0114 member on every entry', () => {
const schema = z.object({ fields: z.record(SnakeKey, z.unknown()) });
const codes = FieldErrorCode.options as readonly string[];
for (const entry of fieldsFor(schema, { fields: { 'Bad Key': 1 } })) {
expect(codes).toContain(entry.code);
}
});

it('leaves an ordinary issue exactly as it was', () => {
const schema = z.object({ a: z.string() });
expect(fieldsFor(schema, {})).toEqual([
{ field: 'a', code: 'required', message: 'Invalid input: expected string, received undefined' },
]);
});

it('still tolerates a container issue with no nested list', () => {
expect(zodIssuesToFields([{ code: 'invalid_key', path: ['m', 'k'], message: 'Invalid key in record' }]))
.toEqual([{ field: 'm.k', code: 'invalid_shape', message: 'Invalid key in record' }]);
expect(zodIssuesToFields([{ code: 'invalid_element', path: ['m'], message: 'Invalid value', issues: 'nope' }]))
.toEqual([{ field: 'm', code: 'invalid_shape', message: 'Invalid value' }]);
});
});
6 changes: 6 additions & 0 deletions packages/spec/api-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@
"VIEW_CONSOLE_ROW_DECORATIONS (const)",
"VIEW_FILTER_OPERATORS (const)",
"VIEW_FILTER_OPERATOR_ALIASES (const)",
"VIEW_METADATA_BRANCHES (const)",
"VIEW_METADATA_MEMBERS (const)",
"VIEW_WRITE_PATH_IDENTITY_KEYS (const)",
"View (type)",
"ViewData (type)",
Expand All @@ -348,6 +350,8 @@
"ViewKind (type)",
"ViewKindSchema (const)",
"ViewMetadata (type)",
"ViewMetadataBranch (type)",
"ViewMetadataDiagnosis (type)",
"ViewMetadataParsed (type)",
"ViewMetadataSchema (const)",
"ViewParsed (type)",
Expand Down Expand Up @@ -382,6 +386,7 @@
"defineTheme (function)",
"defineView (function)",
"defineViewItem (function)",
"diagnoseViewMetadata (function)",
"expandViewContainer (function)",
"expandViewContainerWithDiagnostics (function)",
"isAggregatedViewContainer (function)",
Expand All @@ -393,6 +398,7 @@
"reportForm (const)",
"reportSelectionOrder (function)",
"resolveI18nLabel (function)",
"selectViewMetadataBranch (function)",
"stripViewConsoleDecorations (function)",
"validateActionParams (function)",
"viewForm (const)"
Expand Down
Loading
Loading