Uh oh!
There was an error while loading. Please reload this page.
fix(rest): name the conflicting field in the UNIQUE_VIOLATION 409 body (#7821) - #7930
Conversation
#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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
`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.
Uh oh!
There was an error while loading. Please reload this page.
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_RESTRICTEDandsanitizeRowError'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
uniquefield 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
sanitizeRowError→uniqueViolationColumnand says "A record with thisemailalready 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
uniqueViolationColumnbeing genuinely reachable and reliable at this site. Measured onorigin/main@3c9a67ethrough the realmapDataError, 16 driver shapes:col(error)col(error.message)DETAIL:flattened into messageemailemailDETAIL:onerror.detail(node-postgres)emailundefineddetailbehind a wrappedcauseemailundefinedUNIQUE constraint failed: sys_user.emailemailemailundefinedundefinedindex 'x', sqlite composite, code-only ×3, unknown proseundefinedundefinedPremise holds, and passing the error object beats the bulk path's string read.
sanitizeRowErroronly ever holds a string, so it reads the message channel alone; this site's object read also coversdetailand one step ofcause— 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
409+UNIQUE_VIOLATION+object+field, message naming it.import-runner-error-sanitize.test.ts. Convergence upward only. A new assertion compares both paths' answers for one conflict in one place.index 'x'), composite key, or unparseable prose ⇒ unnamed sentence and nofieldkey at all — asserted asnot.toHaveProperty('field'), not=== undefined, becausefield: nullon 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 derivingemailfromidx_email_unique" goes red.codeANDstatusasserted throughout (ADR-0112) — nevertoThrow(), never "it stopped being a 500".Reverse-verified: with
rest-server.tsreverted 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 aboth halves are populatedguard 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 viauniqueViolationColumnas a bare[A-Za-z_][A-Za-z0-9_$]*, never as driver prose, index names are refused outright, and thetable.qualifier is stripped — sosys_user.emailis reported asemailand the existingnot.toContain('sys_user.email')limb keeps holding.The old
says the same sentence for every dialectpin 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 intobody.error— still fails it.File surface
packages/rest/src/rest-server.ts— theisUniqueViolationErrorbranch;uniqueViolationColumnadded to the existing@objectstack/typesimport.fieldis 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-postgresdetail, cause-wrappeddetail, composite, SQLite index form)..changeset/unique-violation-field-in-409-body.md— patch.packages/rest+ tests.content/docs/protocol/kernel/http-protocol.mdxdocuments 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 nofield. 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 oncode, treatfieldas an enhancement, never guessed). No other doc referencing@objectstack/restdescribes 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-envelopepasses unchanged (therest-server.tsratchet is dialect-facts-only and did not tick up);error-code-ledger.zod.tsregisters codes, not body shapes, and no new code was introduced.Gates
All green:
check:route-envelope,check:authz-resolver,check:meta-type-normalized(the threedispatch-gates.mjsmatched 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-casingcheck:type-check-debt— run afterturbo run build --filter='./packages/*' --filter='./packages/*/*', aslint.ymldoes: "33 ledger entries re-measured, 1789 raw tsc errors total, none above its recorded number." No•line forpackages/rest; no ledger entry raised.packages/resttsc --noEmitclean; fullpackages/restsuite: 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 pristineorigin/mainat line 4342 (shifted to 4383 by this diff's +41 lines) — a config-resolution artifact of invoking eslint directly at the repo root.