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
53 changes: 53 additions & 0 deletions .changeset/adr-0114-field-error-catalog.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/spec": minor
"@objectstack/rest": minor
"@objectstack/objectql": minor
---

feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977)

Settles the vocabulary ADR-0112 D6 deferred, per [ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md).

**`FieldErrorCode` — a closed, lowercase catalog.** 27 members covering what the
six emitters already emit. `FieldErrorSchema.code` tightens from `z.string()` to
this enum, so a validation body's per-field codes are validated for the first time.
`FieldValidationError.code` (objectql) and `FieldCoerceError.code` (rest) stop
being a hand-listed union and a bare `string` respectively and reference the
catalog, so the three cannot drift apart.

Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a
top-level code names the condition the *request* hit, while a field-level code
names the *constraint* the value violated — and constraints are declared in the
metadata's own snake_case, so `max_length` the code and `max_length: 50` the
property are the same word on purpose.

**Zod issue codes no longer reach the wire (wire-visible).** Routes that validate
with Zod passed its vocabulary straight through, so `fields[]` spoke a different
language depending on which route served it, and `too_small` was ambiguous between
a short string, a small number and a short array. `zodIssuesToFields` now maps
using Zod's `origin`/`format`:

| Was | Now |
|:---|:---|
| `too_small` | `min_length` / `min_value` / `min_items` |
| `too_big` | `max_length` / `max_value` / `max_items` |
| `invalid_format` | `invalid_email` / `invalid_url` / `invalid_format` |
| `invalid_value` | `invalid_option` |
| `unrecognized_keys` | `unknown_field` |
| `invalid_union`, `invalid_element`, `invalid_key` | `invalid_shape` |

**A missing required property now reports `required`, not `invalid_type`.** Zod
spells "absent" as a type mismatch against `undefined`, so passing it through made
a form mark a *missing* input as the wrong *type*. The two are indistinguishable on
the issue alone, so the mapper takes the parsed input as an optional argument and
walks the issue path; a caller that cannot supply it keeps `invalid_type` rather
than guessing.

**`unknown_param` → `unknown_field`.** `ActionParamIssue.code` references the
catalog instead of its own literal union; the `param` key beside it already says
what was addressed.

**Not changed:** `EnhancedApiErrorSchema.fieldErrors` keeps its name even though
every producer emits `fields`. Retiring an authorable key needs a tombstone plus a
migration (ADR-0104's contract guard), so it lands on its own — the property now
carries a banner saying which name the wire uses.
39 changes: 36 additions & 3 deletions content/docs/api/error-catalog.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -443,15 +443,48 @@ interface EnhancedApiError {
```typescript
interface FieldError {
field: string; // Field path (supports dot notation)
code: string; // Field-level error code — a SEPARATE vocabulary from
// error.code (`required`, `max_length`, `invalid_email`, …);
// its own catalog is #3977 (ADR-0112 D6)
code: FieldErrorCode; // Which CONSTRAINT the value violated — a closed,
// lowercase catalog, separate from error.code (ADR-0114)
message: string; // Human-readable error for this field
value?: unknown; // The invalid value (if safe to include)
constraint?: unknown; // The constraint that was violated (e.g., max length)
}
```

### Field-level codes

`FieldError.code` is its **own** closed vocabulary, and it is lowercase where
`error.code` is SCREAMING. That is deliberate (ADR-0114): a top-level code names
the condition the *request* hit, while a field-level code names the *constraint*
the value violated — and constraints are declared in the metadata's own
snake_case, so the code and the schema property are the same word.

```
{ required: true } → code 'required'
{ max_length: 50 } → code 'max_length'
{ min_value: 0 } → code 'min_value'
```

| Group | Codes |
|:---|:---|
| Presence and shape | `required`, `invalid_type`, `invalid_shape`, `unknown_field` |
| Per-type parse | `invalid_boolean`, `invalid_number`, `invalid_date`, `invalid_time`, `invalid_email`, `invalid_url`, `invalid_phone`, `invalid_json`, `invalid_format` |
| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `min_items`, `max_items` |
| Closed sets and references | `invalid_option`, `invalid_value`, `reference_not_found`, `reference_ambiguous` |
| Declarative rules | `rule_violation`, `json_schema_violation`, `invalid_initial_state`, `invalid_transition` |

Branch on `code` to decide *how* to mark an input; show `message` to the user.
Routes that parse with Zod map its issue codes into this catalog rather than
passing them through, so `fields[]` speaks one vocabulary whichever route served
it.

<Callout type="warn">
The declared envelope still calls this array `fieldErrors`, while every producer
emits **`fields`**. Read `fields`. The rename is decided but deferred — retiring
an authorable key needs a tombstone plus a migration, so it lands on its own
(ADR-0114 D4).
</Callout>

---

## Client-Side Error Handling
Expand Down
10 changes: 9 additions & 1 deletion content/docs/api/error-handling-client.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,8 @@ Every error response from ObjectStack follows this shape:

{/* os:check */}
```typescript
import type { FieldErrorCode } from '@objectstack/spec/api';

interface ErrorResponse {
success: false; // Always false for error responses
error: {
Expand All@@ -29,9 +31,15 @@ interface ErrorResponse {
retryable?: boolean; // Whether the request can be retried
retryStrategy?: string;
retryAfter?: number; // Seconds to wait before retrying (rate limits)
// ⚠️ Declared as `fieldErrors`; every producer emits `fields`. Read `fields`.
// (ADR-0114 D4 — the rename needs a tombstone + migration, so it lands on
// its own.)
fieldErrors?: Array<{
field: string; // Field path (supports dot notation)
code: string; // Per-field error code (e.g. "INVALID_FORMAT")
// Which CONSTRAINT the value violated — a lowercase `FieldErrorCode`
// (`required`, `max_length`, `invalid_email`, …), NOT a top-level code.
// The two vocabularies are separate on purpose: see ADR-0114.
code: FieldErrorCode;
message: string; // Detail message
value?: unknown; // The invalid value that was provided
constraint?: unknown;
Expand Down
11 changes: 8 additions & 3 deletions content/docs/api/error-handling-server.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,7 +48,10 @@ class TaskAlreadyCompletedError extends AppError {
'RESOURCE_CONFLICT',
`Task ${taskId} is already completed and cannot be modified`,
409,
[{ field: 'status', code: 'lock_conflict', message: 'Task is in terminal state' }],
// No per-field entry: a terminal-state conflict is a condition of the
// RECORD, not a constraint some value violated, and the top-level
// RESOURCE_CONFLICT already says so. `fields[]` is for "this value is
// wrong", which is why its codes name constraints.
);
}
}
Expand All@@ -59,14 +62,16 @@ class InvalidStatusTransitionError extends AppError {
'INVALID_FIELD',
`Cannot transition from '${from}' to '${to}'`,
400,
[{ field: 'status', code: 'invalid_field', message: `Invalid transition: ${from} → ${to}` }],
// `invalid_transition` — the catalog has a member for exactly this, and it
// is more useful to a form than a generic "this field is invalid".
[{ field: 'status', code: 'invalid_transition', message: `Invalid transition: ${from} → ${to}` }],
);
}
}
```

<Callout type="tip">
**Error Codes:** The top-level `code` is SCREAMING_SNAKE (`StandardErrorCode` or a ledger-registered code). Per-field `code` values are a **separate, lowercase vocabulary** — the validators emit `required`, `max_length`, `invalid_email`, … and its catalog is being settled in [#3977](https://github.com/objectstack-ai/objectstack/issues/3977) (ADR-0112 D6). Use the `category` and `httpStatus` that match — `ErrorHttpStatusMap` in `@objectstack/spec/api` maps each category to its HTTP status.
**Error Codes:** The top-level `code` is SCREAMING_SNAKE (`StandardErrorCode` or a ledger-registered code). Per-field `code` values are a **separate, lowercase catalog** — `FieldErrorCode`, closed, naming the constraint the value violated (`required`, `max_length`, `invalid_transition`, …). See the [Error Catalog](/docs/api/error-catalog#field-level-codes); the two vocabularies and why they differ are ADR-0114. Use the `category` and `httpStatus` that match — `ErrorHttpStatusMap` in `@objectstack/spec/api` maps each category to its HTTP status.
</Callout>

---
Expand Down
43 changes: 39 additions & 4 deletions content/docs/references/api/errors.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,8 +30,8 @@ Industry alignment: Google Cloud Errors, AWS Error Codes, Stripe API Errors
## TypeScript Usage

```typescript
import { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
import type { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
import { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, FieldErrorCode, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
import type { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, FieldErrorCode, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';

// Validate data
const result = EnhancedApiError.parse(data);
Expand All@@ -53,7 +53,7 @@ const result = EnhancedApiError.parse(data);
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
| **details** | `any` | optional | Additional error context |
| **fieldErrors** | `{ field: string; code: string; message: string; value?: any; … }[]` | optional | Field-specific validation errors |
| **fieldErrors** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>; message: string; value?: any; … }[]` | optional | Field-specific validation errors (wire name is `fields` — see ADR-0114 D4) |
| **timestamp** | `string` | optional | When the error occurred |
| **requestId** | `string` | optional | Request ID for tracking |
| **traceId** | `string` | optional | Distributed trace ID |
Expand DownExpand Up@@ -100,12 +100,47 @@ const result = EnhancedApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field path (supports dot notation) |
| **code** | `string` | ✅ | Error code for this field (field-level vocabulary — see #3977) |
| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
| **message** | `string` | ✅ | Human-readable error message |
| **value** | `any` | optional | The invalid value that was provided |
| **constraint** | `any` | optional | The constraint that was violated (e.g., max length) |


---

## FieldErrorCode

### Allowed Values

* `required`
* `invalid_type`
* `invalid_shape`
* `unknown_field`
* `invalid_boolean`
* `invalid_number`
* `invalid_date`
* `invalid_time`
* `invalid_email`
* `invalid_url`
* `invalid_phone`
* `invalid_json`
* `invalid_format`
* `min_length`
* `max_length`
* `min_value`
* `max_value`
* `min_items`
* `max_items`
* `invalid_option`
* `invalid_value`
* `reference_not_found`
* `reference_ambiguous`
* `rule_violation`
* `json_schema_violation`
* `invalid_initial_state`
* `invalid_transition`


---

## RetryStrategy
Expand Down
Loading
Loading