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
34 changes: 34 additions & 0 deletions .changeset/adr-0114-d3-mapper-shared-in-spec.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": minor
"@objectstack/types": patch
"@objectstack/rest": patch
"@objectstack/runtime": patch
---

The ADR-0114 D3 mapper (Zod issue codes → the closed `FieldErrorCode` catalog) is now
`zodIssuesToFields`, exported from `@objectstack/spec` (`@objectstack/spec/api`), and it is
the ONE implementation of D3's table in the repo (#8124).

Why: `fields[].code` is declared as a closed catalog (`FieldErrorCode`, ADR-0114 D2), but
`@objectstack/types`' `fieldsFromZodIssues` — the helper the runtime `/analytics`,
`/notifications` and `/automation` entry refusals emit through — passed Zod's own issue
codes through verbatim. A refusal carrying `unrecognized_keys` / `too_small` did not parse
against the schema the protocol declares for it, and the same wire slot spoke two
vocabularies depending on which route served it.

What changed on the wire (all three runtime domain routes):

- `fields[].code` values are now catalog members: `unrecognized_keys` → `unknown_field`,
`too_small` → `min_length`/`min_value`/`min_items` (by origin), `too_big` → the `max_*`
mirrors, enum misses → `invalid_option`, `custom` and any unmapped Zod code →
`invalid_value`.
- A rejection behind a `z.union` is expanded per #5014: the union's own entry is followed
by the branch entries that explain it, so entry count is no longer issue count.
- Two hand-spelled `unrecognized_keys` literals (the analytics `filters` hint and the
automation toggle unknown-key refusal) now say `unknown_field`, the catalog member.

`@objectstack/rest` re-exports the shared implementation from `rest-server.ts` and its
behavior is unchanged (its own mapper tests pin that); `fieldsFromZodIssues` keeps its
signature (plus an optional trailing `input` that upgrades a missing required property from
`invalid_type` to `required`, per the D3 table) and keeps the `'(body)'` spelling for
root-level failures.
2 changes: 1 addition & 1 deletion content/docs/api/error-handling-server.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -165,7 +165,7 @@ Format validation errors so the client can map them to specific form fields:
```typescript
import { z } from 'zod';
import { Hook } from '@objectstack/spec/data';
import { zodIssuesToFields } from '@objectstack/rest';
import { zodIssuesToFields } from '@objectstack/spec/api';

// Map a ZodError onto an AppError carrying field-level errors.
//
Expand Down
310 changes: 14 additions & 296 deletions packages/rest/src/rest-server.ts

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/runtime/src/domains/analytics.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,10 @@ function assertAnalyticsQueryBody(body: unknown): void {
if ('filters' in b && !('where' in b)) {
throw validationFailure(
'`filters` is not an AnalyticsQuery field — use `where` (canonical Query DSL FilterCondition, the same shape find() takes).',
[{ field: 'filters', code: 'unrecognized_keys', message: 'use `where` instead of `filters`' }],
// `unknown_field` — the ADR-0114 catalog member for "a key the
// target does not declare"; this entry used to hand-spell Zod's
// `unrecognized_keys`, a code outside the closed catalog (#8124).
[{ field: 'filters', code: 'unknown_field', message: 'use `where` instead of `filters`' }],
);
}
}
Expand Down
16 changes: 10 additions & 6 deletions packages/runtime/src/domains/automation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -420,11 +420,11 @@ async function refuseUnrelatedScreenRead(
* `invalid_value`, the ADR-0114 catalog's "rejected for a reason no other
* member names".
*
* ⚠️ The Zod branch's per-issue `code` is whatever {@link fieldsFromZodIssues}
* produces, which today is Zod's own vocabulary rather than the ADR-0114 D3
* catalog. That pass-through is this package's, not this route's — `/analytics`
* and `/notifications` emit through the same helper — so it is filed as #8124
* rather than forked here into a third dialect.
* The Zod branch's per-issue `code` is whatever {@link fieldsFromZodIssues}
* produces — since #8124 that is the ADR-0114 D3 catalog: the helper maps
* through `zodIssuesToFields` (`@objectstack/spec`), the same table the REST
* transport applies, so `/analytics`, `/notifications` and this route speak
* one field-code vocabulary instead of leaking Zod's.
*/
function flowDefinitionRefusal(err: any): unknown {
// The producer declared its class; the boundary does not overrule it.
Expand DownExpand Up@@ -767,7 +767,11 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
if (unknownKeys.length > 0) {
throw validationFailure(
`Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the toggle body is { enabled?: boolean }`,
unknownKeys.map((k) => ({ field: k, code: 'unrecognized_keys', message: 'not a toggle field — did you mean `enabled`?' })),
// `unknown_field` — the ADR-0114 catalog member for "a
// key the target does not declare"; this entry used to
// hand-spell Zod's `unrecognized_keys`, a code outside
// the closed catalog (#8124).
unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a toggle field — did you mean `enabled`?' })),
);
}
if ('enabled' in toggleBody && typeof (toggleBody as Record<string, unknown>).enabled !== 'boolean') {
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/api-surface/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -998,6 +998,7 @@
"readServiceSelfInfo (function)",
"resolveDiscoveryEnvironment (function)",
"standardErrorCodeForHttpStatus (function)",
"validateApiEndpointDeclarations (function)"
"validateApiEndpointDeclarations (function)",
"zodIssuesToFields (function)"
]
}
3 changes: 2 additions & 1 deletion packages/spec/export-origins/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -998,6 +998,7 @@
"readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)",
"resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)",
"standardErrorCodeForHttpStatus": "src/api/errors.zod.ts#standardErrorCodeForHttpStatus (function)",
"validateApiEndpointDeclarations": "src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (function)"
"validateApiEndpointDeclarations": "src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (function)",
"zodIssuesToFields": "src/api/zod-issues-to-fields.ts#zodIssuesToFields (function)"
}
}
4 changes: 4 additions & 0 deletions packages/spec/src/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,10 @@ export * from './odata.zod';
export * from './batch.zod';
export * from './http-cache.zod';
export * from './errors.zod';
// The ADR-0114 D3 boundary mapper — the ONE implementation of Zod issue code →
// `FieldErrorCode` in the repo (#8124); `@objectstack/rest` and
// `@objectstack/types` both consume it.
export { zodIssuesToFields } from './zod-issues-to-fields';
export * from './error-code-ledger.zod';
export * from './protocol.zod';
export * from './rest-server.zod';
Expand Down
129 changes: 129 additions & 0 deletions packages/spec/src/api/zod-issues-to-fields.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #8124 — `zodIssuesToFields` in its new home: the ADR-0114 D3 mapper lives in
* this package, beside the `FieldErrorCode` catalog it is total over, so
* `@objectstack/rest` AND `@objectstack/types` read one implementation of the
* table.
*
* The transport-grade behavior pins (every D3 row, union expansion, container
* descent, junk tolerance) live in `packages/rest/src/zod-field-codes.test.ts`
* and `zod-union-fields.test.ts`, which import through rest's re-export — kept
* there deliberately, so the move is proven behavior-identical by the tests
* that pinned the old module-local copy. What THIS file owns is the catalog
* totality claim from the spec side, driven per ADR-0114 D3's own discipline:
* REAL `safeParse` calls against the real `FlowSchema` (the #8055 fixture
* source — the parse whose leaked codes #8124 was filed about), never
* hand-written issue objects.
*/

import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { FieldErrorCode } from './errors.zod';
import { zodIssuesToFields } from './zod-issues-to-fields';
import { FlowSchema } from '../automation/flow.zod';

/** Parse and map, asserting the fixture really fails — with the parsed input. */
const fieldsFor = (schema: { safeParse: (v: unknown) => any }, value: unknown) => {
const r = schema.safeParse(value);
expect(r.success, 'the fixture must actually fail to parse').toBe(false);
return zodIssuesToFields(r.error.issues, value);
};

/** The same, without the input — the degraded path a caller without it takes. */
const fieldsForBlind = (schema: { safeParse: (v: unknown) => any }, value: unknown) => {
const r = schema.safeParse(value);
expect(r.success, 'the fixture must actually fail to parse').toBe(false);
return zodIssuesToFields(r.error.issues);
};

/** A flow definition that parses clean — each fixture below is one edit away. */
const WELL_FORMED_FLOW = {
name: 'welcome_flow',
label: 'Welcome',
type: 'autolaunched',
nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { message: 'hi' } }],
edges: [],
};

describe('zodIssuesToFields — the D3 table against the real FlowSchema (#8124/#8055)', () => {
it('the well-formed control parses clean, so every failure below is the planted edit', () => {
expect(FlowSchema.safeParse(WELL_FORMED_FLOW).success).toBe(true);
});

it('an unknown node key arrives as unknown_field, never unrecognized_keys', () => {
const fields = fieldsFor(FlowSchema, {
...WELL_FORMED_FLOW,
nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }],
});
const unknownKey = fields.find((f) => f.message.includes('next'));
expect(unknownKey, 'the offending key must still be named').toBeDefined();
expect(unknownKey!.code).toBe('unknown_field');
expect(fields.map((f) => f.code)).not.toContain('unrecognized_keys');
});

it('a node missing `label` is required with the input, invalid_type without — never a leak', () => {
const bad = { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }] };

const withInput = fieldsFor(FlowSchema, bad);
const labelEntry = withInput.find((f) => f.field.endsWith('label'));
expect(labelEntry).toBeDefined();
expect(labelEntry!.code).toBe('required');

const blind = fieldsForBlind(FlowSchema, bad);
const blindLabel = blind.find((f) => f.field.endsWith('label'));
expect(blindLabel).toBeDefined();
expect(blindLabel!.code).toBe('invalid_type');
});

it('every code emitted for every #8055-shaped fixture is a catalog member', () => {
const fixtures: unknown[] = [
{ ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }] },
{ ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }] },
{ ...WELL_FORMED_FLOW, name: undefined },
{ ...WELL_FORMED_FLOW, type: 'no_such_flow_type' },
{ ...WELL_FORMED_FLOW, nodes: 'not-an-array' },
'not even an object',
];
for (const fixture of fixtures) {
const fields = fieldsFor(FlowSchema, fixture);
expect(fields.length).toBeGreaterThan(0);
for (const f of fields) {
expect(
() => FieldErrorCode.parse(f.code),
`'${f.code}' leaked onto the wire for ${JSON.stringify(fixture).slice(0, 60)}`,
).not.toThrow();
}
}
});
});

describe('zodIssuesToFields — the ambiguous and unmapped Zod codes land on catalog members', () => {
it('too_small / too_big are split by origin, the D3 disambiguation', () => {
expect(fieldsFor(z.object({ a: z.string().min(3) }), { a: 'x' })[0].code).toBe('min_length');
expect(fieldsFor(z.object({ a: z.number().min(5) }), { a: 1 })[0].code).toBe('min_value');
expect(fieldsFor(z.object({ a: z.array(z.string()).min(2) }), { a: ['one'] })[0].code).toBe('min_items');
expect(fieldsFor(z.object({ a: z.string().max(2) }), { a: 'toolong' })[0].code).toBe('max_length');
expect(fieldsFor(z.object({ a: z.number().max(1) }), { a: 9 })[0].code).toBe('max_value');
expect(fieldsFor(z.object({ a: z.array(z.string()).max(1) }), { a: ['a', 'b'] })[0].code).toBe('max_items');
});

it('custom lands on invalid_value; invalid_union on invalid_shape — catalog members both', () => {
expect(fieldsFor(z.object({ a: z.string().refine(() => false, 'no') }), { a: 'x' })[0].code)
.toBe('invalid_value');
const unionFields = fieldsFor(
z.object({ u: z.union([z.object({ k: z.string() }), z.object({ j: z.number() })]) }),
{ u: { k: 42 } },
);
expect(unionFields[0].code).toBe('invalid_shape');
for (const f of unionFields) {
expect(() => FieldErrorCode.parse(f.code), `'${f.code}' is not a catalog member`).not.toThrow();
}
});

it('tolerates a non-array argument', () => {
for (const junk of [null, undefined, {}, 'issues', 0]) {
expect(zodIssuesToFields(junk)).toEqual([]);
}
});
});
Loading
Loading