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
57 changes: 57 additions & 0 deletions .changeset/unique-violation-field-in-409-body.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/rest": patch
---

fix(rest): the `UNIQUE_VIOLATION` 409 now names the conflicting field, matching the bulk path (#7821)

A single-record write that violated a `unique` field came back as

```json
{"error":"A record with this value already exists","code":"UNIQUE_VIOLATION","object":"invoice"}
```

— no `field`. On an object with several unique fields the caller was told only
that *a* value was taken and had to guess which one, and a client that wanted to
render its own localized message could not name the field either, because the
body carried nothing to name it with.

The platform already knew the answer. Since #6544 the **bulk / import** path
resolves the colliding column through `uniqueViolationColumn` and says *"A record
with this `email` already exists."* The **single-record** path held the same
error object, sat one import from the same helper, and withheld it. One rule, two
implementations, one strictly worse.

The 409 body now carries the field, and its default message reaches parity:

```json
{"error":"A record with this email already exists","code":"UNIQUE_VIOLATION","field":"email","object":"invoice"}
```

**Reading the error object, not its message, resolves more than the bulk path
can.** `sanitizeRowError` only ever holds a string, so it reads the message
channel alone; this site has the whole error, and `uniqueViolationColumn`
additionally reads `detail` and one step of `cause`. That is where the column
actually is for the Postgres driver we ship — node-postgres keeps its
`DETAIL: Key (email)=(…)` line on `error.detail` and off the message — so that
shape now names `email` where a string-only read answers nothing.

**When the driver does not determinably name a column, nothing is guessed.** An
index name (MySQL's `for key 'idx_email_unique'`, SQLite's `index 'x'`), a
composite key, or prose the helper does not parse all produce the unnamed
sentence and **no `field` key at all**. A wrong field name is worse than none: it
sends the user to correct an input that was never the problem. MySQL deployments
therefore keep the unnamed message — that is `uniqueViolationColumn`'s documented
and deliberate cost, not a gap here.

Unaffected: the status is still `409`, the code is still the registered
`UNIQUE_VIOLATION`, `object` is unchanged, and adding `field` is additive. The
bulk path is untouched and still names the field exactly as it did. The
withholding this branch enforces is intact — the offending user data
(`Duplicate entry 'acme@example.com' …`), the index name, and the `table.`
qualifier still never reach the wire; `sys_user.email` is reported as `email`.

Not addressed here: the message is still built-in English. Localizing
platform-built-in error copy is one architectural answer owed to this string,
`DELETE_RESTRICTED` (#7307) and `sanitizeRowError`'s siblings together, and is
deliberately left to that decision. The `field` on the wire is what lets a client
build its own localized message today.
26 changes: 25 additions & 1 deletion content/docs/protocol/kernel/http-protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -735,7 +735,24 @@ enforce-or-remove): authoring it is now a parse error rather than a silent no-op

**Constraint Violations:**
Database constraint failures are surfaced as structured errors. For example, a
unique-constraint violation returns HTTP 409:
unique-constraint violation returns HTTP 409, naming the conflicting field when
the database determinably reports one:

```json
{
"error": "A record with this email already exists",
"code": "UNIQUE_VIOLATION",
"field": "email",
"object": "account"
}
```

<Callout type="info">
`field` is **best-effort and optional**. It is present only when the driver's
error determinably names a *column*; when it names an index instead (MySQL's
`for key 'idx_email_unique'` always does), when the constraint is a **composite**
key, or when the message cannot be parsed, the response omits `field` entirely
and falls back to the unnamed sentence:

```json
{
Expand All@@ -745,6 +762,13 @@ unique-constraint violation returns HTTP 409:
}
```

That degradation is deliberate — a wrong field name would send the user to
correct an input that was never the problem. **Key on `code`, not on the
message**, and treat `field` as an enhancement: it is present when the platform
can prove it, absent when it cannot, and never guessed. The response body never
echoes the driver's own text, the offending value, or the index name.
</Callout>

Cascade behavior on delete (cascade / restrict / set-null) is governed by each
relationship field's configuration in the object schema, enforced by the
ObjectQL engine.
Expand Down
61 changes: 51 additions & 10 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import {
isMcpServerEnabled,
looksLikeInternalErrorLeak,
isUniqueViolationError,
uniqueViolationColumn,
matchMissingColumnOfRelation,
declaresServerFault,
INTERNAL_ERROR_MESSAGE,
Expand DownExpand Up@@ -1072,21 +1073,61 @@ export function mapDataError(error: any, object?: string): { status: number; bod
// this file would have been the fifth private vocabulary, which is the
// defect #6250 is named for.
//
// **The body says nothing the driver said.** The message is a fixed
// sentence and the only interpolated value is the object name the ROUTE
// supplied. That is load-bearing, not incidental: MySQL's text embeds the
// offending USER DATA (`Duplicate entry 'acme@example.com' …`) and
// Postgres' embeds the index and column names, so echoing the driver here
// would trade a status-code bug for an information-disclosure one. Pinned
// in `rest-unique-violation-dialects.test.ts`. The full text still reaches
// the operator: `handleRouteError` / `logWithheldServerFault` log the
// original error untouched.
// **The body still says nothing the driver said.** The message is fixed
// text and the only interpolated values are the object name the ROUTE
// supplied and — since #7821 — the conflicting FIELD, and that second one
// is safe for the same reason the first is: it does not come from the
// driver's prose, it comes from `uniqueViolationColumn`, which hands back
// only a bare `[A-Za-z_][A-Za-z0-9_$]*` identifier it could determine is a
// COLUMN. The withholding this branch exists to enforce is unchanged:
// MySQL's text embeds the offending USER DATA (`Duplicate entry
// 'acme@example.com' …`) and Postgres' embeds the index name, and neither
// can reach the wire — `uniqueViolationColumn` refuses index names outright
// and the table qualifier is stripped (`sys_user.email` → `email`). Pinned,
// per dialect, in `rest-unique-violation-dialects.test.ts`. The full text
// still reaches the operator: `handleRouteError` / `logWithheldServerFault`
// log the original error untouched.
//
// **[#7821] Why `field` at all — parity, not a new feature.** The bulk /
// import path has named the colliding column since #6544
// (`sanitizeRowError` → `uniqueViolationColumn` → "A record with this
// `email` already exists."), while this branch — holding the same error
// object, one import away from the same helper — answered "a value". So the
// platform gave two different answers to one constraint depending only on
// whether the write arrived one row at a time or in a batch, and a client
// that wanted to render its own localized message could not name the field
// either, because the body carried no `field`. Both halves are fixed here:
// the wire gets `field`, and the default sentence reaches parity.
//
// ⚠️ The bulk path is deliberately NOT touched. Convergence is upward only:
// it already names the field and must keep naming it exactly as it does.
//
// **Passing the error OBJECT, not `error.message`, is the point.**
// `sanitizeRowError` only ever holds a string, so it reads the message
// channel alone. This site has the whole error, and `uniqueViolationColumn`
// additionally reads `detail` and one step of `cause` — which is where the
// column actually is for the Postgres driver we ship: node-postgres keeps
// its `DETAIL: Key (email)=(…)` line on `error.detail` and off the message.
// Measured on `origin/main`: that shape resolves `email` from the object and
// `undefined` from `err.message`.
//
// **When it cannot tell, it says nothing.** `uniqueViolationColumn` returns
// `undefined` for an index name (MySQL's `for key 'idx_email_unique'`,
// SQLite's `index 'x'`), for a composite key, and for any dialect it does
// not parse — and then this branch emits the unnamed sentence and NO `field`
// key at all. That degradation is the contract, not a fallback: a wrong
// field name is worse than none, because it sends the user to correct an
// input that was never the problem.
if (isUniqueViolationError(error)) {
const field = uniqueViolationColumn(error);
return {
status: 409,
body: {
error: 'A record with this value already exists',
error: field
? `A record with this ${field} already exists`
: 'A record with this value already exists',
code: 'UNIQUE_VIOLATION',
...(field ? { field } : {}),
...(object ? { object } : {}),
},
};
Expand Down
Loading
Loading