Skip to content

Commit ec5a125

Browse files
hotlongclaude
andauthored
fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (#7821) (#7930)
* fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (#7821) A single-record write violating a `unique` field answered with `code: 'UNIQUE_VIOLATION'`, the `object`, and no `field` — so on an object with several unique fields the caller was told only that *a* value was taken, and a client that wanted to render its own localized message could not name the field either. The platform already resolved this. Since #6544 the bulk/import path runs `sanitizeRowError` -> `uniqueViolationColumn` and says "A record with this `email` already exists."; the single-record branch held the same error object, sat one import from the same helper, and withheld it. One rule, two implementations, one strictly worse. `mapDataError`'s conflict branch now resolves the column and puts it on the wire as `field`, with the default message naming it. Convergence is upward only — the bulk path is untouched. Handing `uniqueViolationColumn` the ERROR rather than `error.message` is load-bearing: `sanitizeRowError` only ever holds a string, so it reads the message channel alone, while this site's object read also covers `detail` and one step of `cause`. Measured, that is where the column is for the Postgres driver we ship — node-postgres keeps its `DETAIL: Key (email)=(...)` line on `error.detail`, off the message. Degradation is the contract, not a fallback: an index name (MySQL always, SQLite's `index 'x'` form), a composite key, or unparseable driver prose yields 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. The withholding this branch enforces is unchanged and re-pinned per dialect: the offending user data, the index name and the `table.` qualifier still never reach the wire (`sys_user.email` -> `email`). The dialect table gains a third face — the `field`, and the dialects that must carry none — plus the node-postgres `detail`, cause-wrapped, composite and SQLite index-form shapes. Its "one fixed sentence" pin is widened to exactly two sentences one bare identifier apart, rather than relaxed, so driver text leaking into `body.error` still goes red. Not addressed: the message is still built-in English. Localizing platform-built-in error copy is one answer owed to this string, #7307 and `sanitizeRowError`'s siblings together. Fixes #7821 * docs(protocol): document the `field` on the UNIQUE_VIOLATION 409 (#7821) `http-protocol.mdx` showed the 409 body this PR changes, so it went stale the moment the field landed — it documented the unnamed sentence with no `field` at all. Flagged by the docs-drift check on #7930. Shows the naming shape, and gives the omission its own callout: `field` is best-effort and absent for an index name (MySQL always), a composite key, or an unparseable message. Clients are told to key on `code` and treat `field` as an enhancement — present when the platform can prove it, never guessed. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3140f9c commit ec5a125

4 files changed

Lines changed: 358 additions & 17 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): the `UNIQUE_VIOLATION` 409 now names the conflicting field, matching the bulk path (#7821)
6+
7+
A single-record write that violated a `unique` field came back as
8+
9+
```json
10+
{"error":"A record with this value already exists","code":"UNIQUE_VIOLATION","object":"invoice"}
11+
```
12+
13+
— no `field`. On an object with several unique fields the caller was told only
14+
that *a* value was taken and had to guess which one, and a client that wanted to
15+
render its own localized message could not name the field either, because the
16+
body carried nothing to name it with.
17+
18+
The platform already knew the answer. Since #6544 the **bulk / import** path
19+
resolves the colliding column through `uniqueViolationColumn` and says *"A record
20+
with this `email` already exists."* The **single-record** path held the same
21+
error object, sat one import from the same helper, and withheld it. One rule, two
22+
implementations, one strictly worse.
23+
24+
The 409 body now carries the field, and its default message reaches parity:
25+
26+
```json
27+
{"error":"A record with this email already exists","code":"UNIQUE_VIOLATION","field":"email","object":"invoice"}
28+
```
29+
30+
**Reading the error object, not its message, resolves more than the bulk path
31+
can.** `sanitizeRowError` only ever holds a string, so it reads the message
32+
channel alone; this site has the whole error, and `uniqueViolationColumn`
33+
additionally reads `detail` and one step of `cause`. That is where the column
34+
actually is for the Postgres driver we ship — node-postgres keeps its
35+
`DETAIL: Key (email)=(…)` line on `error.detail` and off the message — so that
36+
shape now names `email` where a string-only read answers nothing.
37+
38+
**When the driver does not determinably name a column, nothing is guessed.** An
39+
index name (MySQL's `for key 'idx_email_unique'`, SQLite's `index 'x'`), a
40+
composite key, or prose the helper does not parse all produce the unnamed
41+
sentence and **no `field` key at all**. A wrong field name is worse than none: it
42+
sends the user to correct an input that was never the problem. MySQL deployments
43+
therefore keep the unnamed message — that is `uniqueViolationColumn`'s documented
44+
and deliberate cost, not a gap here.
45+
46+
Unaffected: the status is still `409`, the code is still the registered
47+
`UNIQUE_VIOLATION`, `object` is unchanged, and adding `field` is additive. The
48+
bulk path is untouched and still names the field exactly as it did. The
49+
withholding this branch enforces is intact — the offending user data
50+
(`Duplicate entry 'acme@example.com' …`), the index name, and the `table.`
51+
qualifier still never reach the wire; `sys_user.email` is reported as `email`.
52+
53+
Not addressed here: the message is still built-in English. Localizing
54+
platform-built-in error copy is one architectural answer owed to this string,
55+
`DELETE_RESTRICTED` (#7307) and `sanitizeRowError`'s siblings together, and is
56+
deliberately left to that decision. The `field` on the wire is what lets a client
57+
build its own localized message today.

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,24 @@ enforce-or-remove): authoring it is now a parse error rather than a silent no-op
735735

736736
**Constraint Violations:**
737737
Database constraint failures are surfaced as structured errors. For example, a
738-
unique-constraint violation returns HTTP 409:
738+
unique-constraint violation returns HTTP 409, naming the conflicting field when
739+
the database determinably reports one:
740+
741+
```json
742+
{
743+
"error": "A record with this email already exists",
744+
"code": "UNIQUE_VIOLATION",
745+
"field": "email",
746+
"object": "account"
747+
}
748+
```
749+
750+
<Callout type="info">
751+
`field` is **best-effort and optional**. It is present only when the driver's
752+
error determinably names a *column*; when it names an index instead (MySQL's
753+
`for key 'idx_email_unique'` always does), when the constraint is a **composite**
754+
key, or when the message cannot be parsed, the response omits `field` entirely
755+
and falls back to the unnamed sentence:
739756

740757
```json
741758
{
@@ -745,6 +762,13 @@ unique-constraint violation returns HTTP 409:
745762
}
746763
```
747764

765+
That degradation is deliberate — a wrong field name would send the user to
766+
correct an input that was never the problem. **Key on `code`, not on the
767+
message**, and treat `field` as an enhancement: it is present when the platform
768+
can prove it, absent when it cannot, and never guessed. The response body never
769+
echoes the driver's own text, the offending value, or the index name.
770+
</Callout>
771+
748772
Cascade behavior on delete (cascade / restrict / set-null) is governed by each
749773
relationship field's configuration in the object schema, enforced by the
750774
ObjectQL engine.

packages/rest/src/rest-server.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isMcpServerEnabled,
1010
looksLikeInternalErrorLeak,
1111
isUniqueViolationError,
12+
uniqueViolationColumn,
1213
matchMissingColumnOfRelation,
1314
declaresServerFault,
1415
INTERNAL_ERROR_MESSAGE,
@@ -1072,21 +1073,61 @@ export function mapDataError(error: any, object?: string): { status: number; bod
10721073
// this file would have been the fifth private vocabulary, which is the
10731074
// defect #6250 is named for.
10741075
//
1075-
// **The body says nothing the driver said.** The message is a fixed
1076-
// sentence and the only interpolated value is the object name the ROUTE
1077-
// supplied. That is load-bearing, not incidental: MySQL's text embeds the
1078-
// offending USER DATA (`Duplicate entry 'acme@example.com' …`) and
1079-
// Postgres' embeds the index and column names, so echoing the driver here
1080-
// would trade a status-code bug for an information-disclosure one. Pinned
1081-
// in `rest-unique-violation-dialects.test.ts`. The full text still reaches
1082-
// the operator: `handleRouteError` / `logWithheldServerFault` log the
1083-
// original error untouched.
1076+
// **The body still says nothing the driver said.** The message is fixed
1077+
// text and the only interpolated values are the object name the ROUTE
1078+
// supplied and — since #7821 — the conflicting FIELD, and that second one
1079+
// is safe for the same reason the first is: it does not come from the
1080+
// driver's prose, it comes from `uniqueViolationColumn`, which hands back
1081+
// only a bare `[A-Za-z_][A-Za-z0-9_$]*` identifier it could determine is a
1082+
// COLUMN. The withholding this branch exists to enforce is unchanged:
1083+
// MySQL's text embeds the offending USER DATA (`Duplicate entry
1084+
// 'acme@example.com' …`) and Postgres' embeds the index name, and neither
1085+
// can reach the wire — `uniqueViolationColumn` refuses index names outright
1086+
// and the table qualifier is stripped (`sys_user.email` → `email`). Pinned,
1087+
// per dialect, in `rest-unique-violation-dialects.test.ts`. The full text
1088+
// still reaches the operator: `handleRouteError` / `logWithheldServerFault`
1089+
// log the original error untouched.
1090+
//
1091+
// **[#7821] Why `field` at all — parity, not a new feature.** The bulk /
1092+
// import path has named the colliding column since #6544
1093+
// (`sanitizeRowError` → `uniqueViolationColumn` → "A record with this
1094+
// `email` already exists."), while this branch — holding the same error
1095+
// object, one import away from the same helper — answered "a value". So the
1096+
// platform gave two different answers to one constraint depending only on
1097+
// whether the write arrived one row at a time or in a batch, and a client
1098+
// that wanted to render its own localized message could not name the field
1099+
// either, because the body carried no `field`. Both halves are fixed here:
1100+
// the wire gets `field`, and the default sentence reaches parity.
1101+
//
1102+
// ⚠️ The bulk path is deliberately NOT touched. Convergence is upward only:
1103+
// it already names the field and must keep naming it exactly as it does.
1104+
//
1105+
// **Passing the error OBJECT, not `error.message`, is the point.**
1106+
// `sanitizeRowError` only ever holds a string, so it reads the message
1107+
// channel alone. This site has the whole error, and `uniqueViolationColumn`
1108+
// additionally reads `detail` and one step of `cause` — which is where the
1109+
// column actually is for the Postgres driver we ship: node-postgres keeps
1110+
// its `DETAIL: Key (email)=(…)` line on `error.detail` and off the message.
1111+
// Measured on `origin/main`: that shape resolves `email` from the object and
1112+
// `undefined` from `err.message`.
1113+
//
1114+
// **When it cannot tell, it says nothing.** `uniqueViolationColumn` returns
1115+
// `undefined` for an index name (MySQL's `for key 'idx_email_unique'`,
1116+
// SQLite's `index 'x'`), for a composite key, and for any dialect it does
1117+
// not parse — and then this branch emits the unnamed sentence and NO `field`
1118+
// key at all. That degradation is the contract, not a fallback: a wrong
1119+
// field name is worse than none, because it sends the user to correct an
1120+
// input that was never the problem.
10841121
if (isUniqueViolationError(error)) {
1122+
const field = uniqueViolationColumn(error);
10851123
return {
10861124
status: 409,
10871125
body: {
1088-
error: 'A record with this value already exists',
1126+
error: field
1127+
? `A record with this ${field} already exists`
1128+
: 'A record with this value already exists',
10891129
code: 'UNIQUE_VIOLATION',
1130+
...(field ? { field } : {}),
10901131
...(object ? { object } : {}),
10911132
},
10921133
};

0 commit comments

Comments
 (0)