Skip to content

mapDataError has no DUPLICATE_RECORD arm: the engine's insert conflict envelope reaches the wire through the generic passthrough, dropping the field key and the user-facing conflict sentence #14389

Description

@os-musk

Found while implementing #14095 (the ObjectQL half of the 2026-09-01 unique-violation ruling). The engine change is correct and lands on its own; this is the REST-side half of the same condition, in domain:cli territory, filed rather than ridden on that PR.

What changes, and where

Since #14095 engine.insert answers a driver unique violation with an ADR-0112 envelope: code: 'DUPLICATE_RECORD', status: 409, the driver error whole on cause, and field when uniqueViolationColumn determinably named the conflicting column.

classifyDataError (packages/rest/src/error-response.ts) reaches the declared-status passthrough arm first, because the envelope declares a status. That arm returns { status, body: { error, ...thrownCodeFields(error, status), object } } — it ships no structured fields of its own. The dedicated isUniqueViolationError arm further down, which builds 409 UNIQUE_VIOLATION with a field key, is never reached for an insert conflict any more.

Measured, end to end

Real engine, real drivers, the real mapDataError. AFTER is the envelope; BEFORE is the same conflict's raw driver error handed to the same boundary:

driver index status code field on the body
driver-sqlite-wasm single column 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD emailabsent
driver-sqlite-wasm composite 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent (composite names none, by contract)
driver-memory single column 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent
driver-memory composite 409 → 409 UNIQUE_VIOLATIONDUPLICATE_RECORD absent → absent

So: the status is unaffected — the passthrough honours the declared 409 on every driver, which is the good half. Two things do move:

  1. The field key is gone on the dialects that name a column. That key is what an import UI or a Console form highlights ("A record with this email already exists"), and it is the whole point of import-runner's sanitizeRowError keeps its own three-dialect unique regexes — deferred from #6250 to avoid a merge race with #4633 #6544 / REST: the UNIQUE_VIOLATION 409 message is hard-coded English and carries no field — and it is less informative than the bulk path's own message for the same constraint #7821.
  2. The sentence changes from A record with this email already exists to the engine's own Duplicate record refused on 'duly_note': a unique constraint on 'email' already holds this value. No record was written. Accurate, but it is the platform's sentence, not the one the 409 arm curated for an end user.

For driver-memory the change is an improvement in one respect: that driver's own refusal already declared status: 409, so it ALREADY took the passthrough, and its raw message — which echoes the offending values as JSON (a record with the values {"email":"a@b.example"} already exists) — was reaching the client. The envelope replaces it with a sentence carrying no values.

The shape of the fix

A dedicated DUPLICATE_RECORD arm in classifyDataError, placed with the other structured 409s (DELETE_RESTRICTED, CONCURRENT_UPDATE) ahead of the declared-status passthrough — that placement already exists in the file for exactly this reason ("Surfaced FIRST so the structured fields survive the generic catch-alls"). The envelope carries everything such an arm needs: object, field, developerMessage, and cause.

Open questions for whoever takes it, both wire-contract decisions rather than mechanics:

  • Which code should the wire speakDUPLICATE_RECORD (what the producer now declares) or UNIQUE_VIOLATION (what clients see today)? Both are registered. The ADR-0112 rule is that the producer names the condition, which argues for the former; back-compatibility argues for the latter, and there is a third option where the arm keeps UNIQUE_VIOLATION on the wire and puts the producer's spelling in declaredCode.
  • Which sentence — the engine's, or the arm's curated end-user wording plus developerMessage beside it (the DELETE_RESTRICTED split).

Not a regression in the importer

Measured separately: the import row report READS err.message and err.code (toFailedResultsanitizeRowError). Its code improves from a dialect token (SQLITE_CONSTRAINT_UNIQUE, 11000) to DUPLICATE_RECORD, and the message is the engine's sentence, which the SQL backstop passes through unchanged (it names no statement). No fix owed there.


Triage housekeeping (R+98): the Blocked-by: #14095 line has been removed from this body. #14095 closed as completed on 2026-09-02T06:52Z via PR #14405 — the envelope this card reacts to is on main now, so the line was inert and misrepresented the card as gated. This card is pm:dispatched with PR #14544 open against it, which is the live state; nothing else changes.

Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:clipriority:p1High: required for production / M2

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions