Skip to content

fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (#7821) - #7930

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-7821-unique-violation-field
Aug 12, 2026
Merged

fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (#7821)#7930
hotlong merged 2 commits into
mainfrom
claude/issue-7821-unique-violation-field

Conversation

@hotlong

@hotlonghotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes#7821

Implements ruling (b) only. ⛔ (a) — routing the message through the i18n service — is deliberately NOT here: #7307 reports the identical class on DELETE_RESTRICTED and sanitizeRowError's sibling constants are the same family, so how platform-built-in error copy gets localized is one architectural answer owed to all of them at once. The card says (b) alone unblocks clients. No i18n keys, locale bundles, or message-resolution layer were added.

The defect

A single-record write violating a unique field answered with the code, the object, and no field:

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

The platform already knew the answer. Since #6544 the bulk / import path runs sanitizeRowErroruniqueViolationColumn 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 — on an object with several unique fields the caller had to guess which one collided, and a client wanting to render its own localized message could not name the field either.

Now:

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

Premise verification (run before any code was written)

The ruling hangs on uniqueViolationColumn being genuinely reachable and reliable at this site. Measured on origin/main @ 3c9a67e through the real mapDataError, 16 driver shapes:

shapecol(error)col(error.message)
pg DETAIL: flattened into messageemailemail
pg DETAIL: on error.detail (node-postgres)emailundefined
pg detail behind a wrapped causeemailundefined
sqlite UNIQUE constraint failed: sys_user.emailemailemail
mysql ×5 (bare / knex / code / errno / cause)undefinedundefined
pg constraint-name form, pg composite, sqlite index 'x', sqlite composite, code-only ×3, unknown proseundefinedundefined

Premise holds, and passing the error object beats the bulk path's string read.sanitizeRowError only ever holds a string, so it reads the message channel alone; this site's object read also covers detail and one step of cause — which is where the column actually is for the Postgres driver we ship. Degradation is clean in all 12 non-naming shapes: undefined, never garbage.

The four consequences, each pinned

  1. Names the field409 + UNIQUE_VIOLATION + object + field, message naming it.
  2. Bulk path unchanged. Not edited; still pinned in import-runner-error-sanitize.test.ts. Convergence upward only. A new assertion compares both paths' answers for one conflict in one place.
  3. Degradation. Index name (MySQL always, SQLite's index 'x'), composite key, or unparseable prose ⇒ unnamed sentence and no field key at all — asserted as not.toHaveProperty('field'), not === undefined, because field: null on the wire is the same defect wearing a different type. A wrong field name is worse than none. MySQL is asserted by name, so a future "improve it by deriving email from idx_email_unique" goes red.
  4. code AND status asserted throughout (ADR-0112) — never toThrow(), never "it stopped being a 500".

Reverse-verified: with rest-server.ts reverted and the tests kept, 7 assertions go red; with the fix applied, 78 pass. The degradation half correctly passes both ways (it is the invariant), so a both halves are populated guard keeps the new pin from being vacuous.

Disclosure boundary — unchanged, and that is the check that matters

This branch exists partly to withhold driver text: MySQL embeds the offending user data (Duplicate entry 'acme@example.com' …) and Postgres the index name. All of that still never reaches the wire, and the pre-existing per-dialect assertions are untouched and still green. The column is the one item in that list safe to name: it arrives via uniqueViolationColumn as a bare [A-Za-z_][A-Za-z0-9_$]*, never as driver prose, index names are refused outright, and the table. qualifier is stripped — so sys_user.email is reported as email and the existing not.toContain('sys_user.email') limb keeps holding.

The old says the same sentence for every dialect pin is widened, not deleted: it now asserts set-equality against exactly two sentences one bare identifier apart, so a third distinct sentence — driver text leaking into body.error — still fails it.

File surface

  • packages/rest/src/rest-server.ts — the isUniqueViolationError branch; uniqueViolationColumn added to the existing @objectstack/types import. field is already this file's error-body key convention.
  • packages/rest/src/rest-unique-violation-dialects.test.ts — third face on the shared table, four new dialect shapes (node-postgres detail, cause-wrapped detail, composite, SQLite index form).
  • .changeset/unique-violation-field-in-409-body.md — patch.

⚠️One declared-surface breach, reported rather than worked around. The declared surface was packages/rest + tests. content/docs/protocol/kernel/http-protocol.mdx documents the exact 409 body this PR changes — it was flagged by the docs-drift check and went stale the moment the field landed, still showing the unnamed sentence with no field. Shipping a change that makes the protocol doc for that change wrong is worse than the breach, so it is fixed here in a separate commit: the naming shape, plus a callout giving the omission its own contract (best-effort, absent for index names / composite keys / unparseable messages; key on code, treat field as an enhancement, never guessed). No other doc referencing @objectstack/rest describes this body; the three release-owned pages are read-only and untouched.

No envelope or route-ledger gate pins the 409 body shape.check:route-envelope passes unchanged (the rest-server.ts ratchet is dialect-facts-only and did not tick up); error-code-ledger.zod.ts registers codes, not body shapes, and no new code was introduced.

Gates

All green:

  • check:route-envelope, check:authz-resolver, check:meta-type-normalized (the three dispatch-gates.mjs matched for the source paths)
  • check:docs-audit-scope, check:quick-reference-counts, check:role-word, check:doc-formula-expressions (matched for the docs path)
  • check:changeset-gate-self-tests, check:objectui-changeset, check:empty-changeset, check:release-notes, check-changeset-no-major (matched for .changeset)
  • check:nul-bytes, check:error-code-casing
  • check:type-check-debt — run after turbo run build --filter='./packages/*' --filter='./packages/*/*', as lint.yml does: "33 ledger entries re-measured, 1789 raw tsc errors total, none above its recorded number." No line for packages/rest; no ledger entry raised.
  • Build closure green; packages/resttsc --noEmit clean; full packages/rest suite: 92 files, 1519 tests, all passing.

One pre-existing lint error is untouched by this PR: rest-server.ts@typescript-eslint/ban-ts-comment"Definition for rule … was not found", present identically on pristine origin/main at line 4342 (shifted to 4383 by this diff's +41 lines) — a config-resolution artifact of invoking eslint directly at the repo root.

#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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 5:20am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest)
  • content/docs/api/index.mdx(via @objectstack/rest)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest)
  • content/docs/permissions/system-context.mdx(via packages/rest)
  • content/docs/plugins/index.mdx(via @objectstack/rest)
  • content/docs/plugins/packages.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest)

3 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest)
  • content/docs/releases/v12.mdx(via @objectstack/rest)
  • content/docs/releases/v17.mdx(via @objectstack/rest)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
`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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@hotlong@claude