Skip to content

Commit 5aad4e0

Browse files
fix(types): a unique refusal names the field by its label, not its API key (#26934)
Fixes #26867 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:唯一索引冲突(409 `UNIQUE_VIOLATION`)给用户看的那句 `error` 现在用字段的标签称呼字段(「A record with this Global SKU already exists」),不再用 API 键 `gsku`;`field` 照旧带 API 键给程序用。 - **为什么改**:之前开发者在 `developerMessage` 里看到的是标签,终端用户看到的反而是 `gsku`,两半颠倒了;标签按写入请求的语言取(有翻译用翻译,否则用声明的 label),没有标签就退回通用句。 - **风险与回滚**:只改了 409 正文里 `error` 这一句的文字,`code`、状态码、`field` 都没变;`developerMessage` 在没有翻译时一字不差,有翻译时跟着请求语言用翻译后的标签;若有客户端按旧句子文字解析字段名会受影响(应改读 `field`)。回滚就是 revert 这个 PR。 - **先看**:`packages/objectql/src/duplicate-record-error.ts`(`displayLabels`:标签和语言在这里解析一次,`fieldLabel` 只在确有标签时出现);`packages/types/src/data-error-classification.ts`(两处 409 分支:信封分支改用 `fieldLabel`,裸驱动错误分支改成通用句);`packages/core/src/utils/import-runner.ts`(`sanitizeRowError` 的唯一冲突分支改成通用句)。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/engine-duplicate-record-label-sentence.test.ts`,应看到 33 个用例全过,包括 zh-CN 下句子写成「A record with this 客户名称 already exists」的那条。 - **需要你做的**:无,席位落地。 ## What changes The single-column unique refusal answered `409 UNIQUE_VIOLATION` with `error: "A record with this gsku already exists"`, which names the field by its API key. `developerMessage` named the same field by its label, so the developer read the label and the end user read the key. Now: - **`@objectstack/objectql` (the producer).** The engine already resolves the field's label for its own sentence (`buildDuplicateMessage`, #24951). `DuplicateRecordError` now carries that same resolution as `fieldLabel`, set only when the field has a label. The resolution moves onto the ladder the record validator and the `DELETE_RESTRICTED` refusal already use (`resolveFieldLabel` / `objectDisplayLabel`: translation bundle for the writer's locale, then the declared `label`). That ladder runs in the write's message context, which every engine write door already passes to `envelopeDriverWriteRefusal`. `envelopeUniqueViolation` takes that context as an optional fourth argument. With no context it reads the declared labels exactly as before. - **`@objectstack/types` (the classification).** The envelope arm writes `A record with this LABEL already exists` from `fieldLabel` and falls back to the generic sentence when there is none. `field` (API key), `code`, status, `developerMessage` and `object` are unchanged. The raw-driver arm (a unique error no engine door wrapped) has no schema, and therefore no label, in hand. Per the card ("falls back to the generic sentence when no label is in hand") it now answers the generic sentence, and `field` still carries the column. - **`@objectstack/core` (`sanitizeRowError`).** Measured: the `column` at the old line 370 is the driver's column identifier, which is the field's API key (`phone_number`, `email`, `emailAddress` in the dialect table), not a label. That is the same defect, so its unique branch answers the generic sentence. This keeps the parity its own docblock declares with `mapDataError`. Reach: an import row reaches this branch only when the create door's verdict was not adopted first (`toFailedResult` → `adoptDoorVerdict` takes `UNIQUE_VIOLATION` whole, pinned by `import-row-schema-drift-20701.test.ts`). So the import's row text comes from the classification above, and this branch is reached only by a direct caller of the exported helper or by an error carrying a `fields` finding. - **Landing outside the claimed file surface:** `packages/objectql/src/duplicate-record-error.ts` and `not-null-refusal-error.ts`. The classification (`mapDataError(error, object)`) holds no schema and no locale, so the label can reach it only on the envelope. The fix is therefore made at the producer, which the standing order allows. It is not a second lookup: the doors read the one resolution the engine made. - **Locale reading (asked for by the dispatch).** The classification itself has neither the request locale nor a translated label. The engine's write exit has both: `this.validationMessageContext(object, execCtx)` (the negotiated locale plus the i18n service's `t`) is passed at all five `envelopeDriverWriteRefusal` call sites in `engine.ts`. So the translated label is used, through the existing resolvers, and no locale path was added. The English frame of the sentence is unchanged (see Acceptance notes). - **Checklist:** `records-forms.field-unique-enforcement` goes to revision 12. acceptance[0]'s clause now states the label rule where it said the `error` "stays generic", automated.ref's sentence follows it, and a history row is added. The dogfood pin's exact body expects `A record with this Global SKU already exists`. ## Tests - `engine-duplicate-record-label-sentence.test.ts` (objectql), on each of the four write doors (insert, batch insert, update by id, update by predicate): - `fieldLabel` equals the declared label, and `mapDataError` answers 409 / `UNIQUE_VIOLATION` / `field: 'name'` / `A record with this Account Name already exists`; - with no label, `fieldLabel` is absent and the answer is the generic sentence; - in `zh-CN` with a bundle, both sentences carry the translated labels; - in an untranslated locale they carry the declared labels. - `rest-duplicate-record-arm.test.ts`: the labelled envelope names the label, and an unlabelled envelope gives the generic sentence with `field` kept. The exact body key set shows `fieldLabel` is not relayed. §2 (real engine, real better-sqlite3) answers `A record with this Email already exists`, and the raw arm answers the generic sentence. - `rest-unique-violation-dialects.test.ts`: one sentence for every dialect, the key only on `field`, and parity with `sanitizeRowError`. - `error-response-structured-arm-door-parity.test.ts`: both doors give the labelled sentence. - `import-runner-error-sanitize.test.ts`: face 2 expects the generic sentence for every conflict. - `field-unique-rest-refusal.dogfood.test.ts`: the exact body on a booted stack. ## Acceptance notes - **The sentence frame stays English.** In `zh-CN` the user-facing `error` reads `A record with this 客户名称 already exists`. Every fixed sentence the REST classification writes is English today, and no message catalog key exists for this one. Localising the frame would be a new catalog entry, which is outside this card. Noted, not filed: no carrier. - **`developerMessage` now carries translated labels in a translated locale.** It is the engine's `message`, and one label resolution feeds both sentences. With no i18n service or no translation it is byte-identical to before. - **The object label in the engine's `message`** is now also read in the writer's locale (`objectDisplayLabel`), for the same reason: one resolution per sentence. - **Historical tables left as they are.** The header table of `import-row-schema-drift-20701.test.ts` and the BEFORE/AFTER block of `rest-duplicate-record-arm.test.ts` are dated measurements on named bases, and they still quote the old key sentence. - **Reverse verification (one-shot, no test file kept).** Each leg was mutated through `scripts/ablation-replace.mjs` with an anchor that must hit, rebuilt, and checked with `scripts/ablation-dist-preflight.mjs` where the suite reads `dist/`. Each was then restored to blob == HEAD and re-run green. Readings are under Verification. ## Verification <details><summary>Package suites</summary> On the final head `2423a854b4` (after `git merge origin/main`, `pnpm install --frozen-lockfile`, and a rebuild of the dogfood closure): - `pnpm --filter @objectstack/objectql test`: Test Files 475 passed (475), Tests 8865 passed (8865) - `pnpm --filter @objectstack/rest test`: Test Files 329 passed (329), Tests 6527 passed, 326 skipped (6853) - dogfood `test/field-unique-rest-refusal.dogfood.test.ts`: Test Files 1 passed (1), Tests 5 passed (5) On the pre-merge head `02f4d309e3` (`types` and `core` are untouched by the merge): - `pnpm --filter @objectstack/types test`: Test Files 29 passed (29), Tests 789 passed (789) - `pnpm --filter @objectstack/core test`: Test Files 124 passed (124), Tests 2803 passed (2803) - `pnpm --filter @objectstack/objectql test`: Test Files 475 passed (475), Tests 8859 passed (8859) - `pnpm --filter @objectstack/rest test`: Test Files 328 passed (328), Tests 6515 passed, 326 skipped (6841) - `typecheck` for types, core, objectql and rest: all Done, exit 0 - dogfood `test/field-unique-rest-refusal.dogfood.test.ts`: Test Files 1 passed (1), Tests 5 passed (5) - `pnpm exec eslint` on the 11 changed `.ts` files: exit 0 </details> <details><summary>Ablations (mutate, rebuild, preflight, red; then restore, rebuild, preflight --absent, green)</summary> 1. The envelope arm interpolates `field` instead of `label`. Preflight: marker present in `packages/types/dist/index.js` and `index.mjs`. rest: 8 failed of 101. objectql label file: 8 failed of 33. Restore: marker absent from all 12 built files, tree clean against HEAD, 101/101 and 33/33. 2. The raw arm names the column again. rest dialects + arm: 9 failed of 113. Restore: marker absent, 113/113. 3. `sanitizeRowError` names the SQLite column again (source-resolved suite). 2 failed of 62. Restore: blob == HEAD, 62/62. 4. The envelope drops the message context from the field label (source-resolved suite). The 4 locale cases failed, 4 of 33. Restore: blob == HEAD, 33/33. </details> <details><summary>Gates</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) on head `2423a854b4`, the merge of `origin/main`. It names 72 commands, the same list as on the pre-merge head `02f4d309e3`. All 72 were run on `2423a854b4`, each exit code captured before any pipe: **72 x exit 0**. `pnpm check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET` (exit 3, nothing measured) on the first pre-merge pass, before the full dependency closure was built. It was 0 on re-run, and 0 on the final head: "112 published require entry point(s) across 69 package(s) load". Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list` gives "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: all 72 recorded an exit code). Lane blind spot: `pnpm exec eslint` on the changed files is exit 0. The repo-wide `pnpm lint`, the artifact-roster families, the type-check lanes and the push-only batteries (Dogfood Regression Gate, Temporal Conformance) are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aa00362 commit 5aad4e0

13 files changed

Lines changed: 327 additions & 110 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/core': patch
5+
---
6+
7+
fix(types): a unique refusal tells the user which field by its label, never by its API key
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A create or update refused by a single-column unique index answered `409 UNIQUE_VIOLATION` with a user-facing `error` that named the field by its API key ("A record with this gsku already exists"), while `developerMessage` named it by its label. The split was inverted: the developer read the label and the end user read `gsku`.
13+
14+
- **The user-facing `error` names the field by its label.** For a field labelled `Global SKU`, the body now reads `"error": "A record with this Global SKU already exists"`. `field` still carries the API key (`gsku`) for programs, and `code`, the status and `developerMessage` are unchanged.
15+
- **In the writer's locale.** The label is read the same way the record validator and the `DELETE_RESTRICTED` refusal read theirs: the translation bundle for the request's locale, then the declared `label`. That one label feeds both the user-facing sentence and the engine's own `message` (the `developerMessage` on the wire). A request in `zh-CN` against a translated object reads its labels in `zh-CN` in both. The sentence around the label stays English.
16+
- **No label, no name.** When the field declares no label and the bundle holds none, the sentence is the generic `"A record with this value already exists"`, and `field` still names the key. The same applies to a raw driver unique error that no engine write door wrapped, since it carries no schema to read a label from, and to `sanitizeRowError` (`@objectstack/core`), the import runner's text cleaner. The composite-index answer is unchanged.
17+
- **`DuplicateRecordError` gains `fieldLabel`** (`@objectstack/objectql`): the conflicting field's display name in the writer's locale, set only when the field has one. `envelopeUniqueViolation` takes the write's message context as an optional fourth argument, and every engine write door passes it.
18+
19+
If a client matched the old sentence text to find the field, read `field` instead. It has carried the API key on this body all along.

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4531,7 +4531,7 @@
45314531
"title": "Field-level `unique` on the write path: colliding writes refused 409 UNIQUE_VIOLATION on both driver families, NULL-distinct, organization-scoped by default, whole-batch refusal, autonumber resync converges",
45324532
"since": "v17",
45334533
"status": "active",
4534-
"revision": 11,
4534+
"revision": 12,
45354535
"priority": "P1",
45364536
"surface": "api",
45374537
"personas": [
@@ -4560,9 +4560,9 @@
45604560
],
45614561
"acceptance": [
45624562
{
4563-
"clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT by its declared label ('Duplicate record refused on 'LABEL': …'), by its API name ('qa_unique') only where the object declares no label, and states 'No record was written.', while the user-facing `error` stays generic ('A record with this value already exists'); NEITHER carries a driver prefix, a driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion",
4563+
"clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT by its declared label ('Duplicate record refused on 'LABEL': …'), by its API name ('qa_unique') only where the object declares no label, and states 'No record was written.', while the user-facing `error` names the field by its label ('A record with this LABEL already exists') where a single offending column is reported and the field has a label, and is otherwise generic ('A record with this value already exists') — never by its API key, which stays on `field`; NEITHER carries a driver prefix, a driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion",
45644564
"oracle": "api",
4565-
"verify": "the refusal body — status, code, and the developerMessage naming the object, read from developerMessage, not from the generic `error` — + a post-refusal count; the SQL-family envelope is single-sourced at packages/objectql/src/duplicate-record-error.ts (buildDuplicateMessage / envelopeUniqueViolation); the memory driver's `object.field` sentence (memory-unique-constraint.ts uniqueViolationError) is asserted by memory-unique-constraint.test.ts, not live",
4565+
"verify": "the refusal body — status, code, and the developerMessage naming the object, read from developerMessage, not from the user-facing `error` — + a post-refusal count; the SQL-family envelope is single-sourced at packages/objectql/src/duplicate-record-error.ts (buildDuplicateMessage / envelopeUniqueViolation); the memory driver's `object.field` sentence (memory-unique-constraint.ts uniqueViolationError) is asserted by memory-unique-constraint.test.ts, not live",
45664566
"evidence": "refusal response + before/after counts"
45674567
},
45684568
{
@@ -4607,7 +4607,7 @@
46074607
],
46084608
"automated": {
46094609
"kind": "dogfood",
4610-
"ref": "packages/qa/dogfood/test/field-unique-rest-refusal.dogfood.test.ts, at the REST data doors of a booted stack on the stock SQL family (bootStack's SQLite datasource), on the scratch object the fixtures prescribe (sku unique:true, gsku unique:'global', nick): acceptance[0], negative[0] and negative[1] (a colliding create is 409 UNIQUE_VIOLATION; the developerMessage names the object by its label and says 'No record was written.'; on the tenant-scoped composite index the error is the generic 'A record with this value already exists', on the single-column global index it names the field; no driver prefix, statement or bound value; the colliding row is not stored), acceptance[1] (two rows with no sku both land), acceptance[2] (a PATCH onto a taken value is 409 and the row keeps its value; a PATCH leaving sku alone, or re-sending its own value, is 200) and acceptance[3] with negative[2] at the REST door that stamps one value onto two rows, POST /data/OBJECT/updateMany with options.atomic (its per-row report refuses both, ROLLED_BACK then UNIQUE_VIOLATION, and both rows re-read unchanged; REST has no predicate-update door, so the engine's predicate updateMany stays the memory pin's). packages/drivers/driver-memory/src/memory-unique-constraint.test.ts — acceptance[4]'s memory-family legs (the \"[#13197] scoping matches driver-sql's ADR-0120 D1/D3 rule\" describe), acceptance[5] ('the refusal is recognisable to the ENGINE, so the autonumber resync converges'), negative[2]'s predicate half ('updateMany refuses BEFORE mutating anything — no half-applied batch') and negative[3]'s memory-family half, at the memory driver; + packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts + packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts — acceptance[4]'s SQL-family legs and negative[3]'s SQL-family half, at SqlDriver over better-sqlite3, through a test-local createAsApi mapping; driver-level evidence beside acceptance[0]–[3], never their pass"
4610+
"ref": "packages/qa/dogfood/test/field-unique-rest-refusal.dogfood.test.ts, at the REST data doors of a booted stack on the stock SQL family (bootStack's SQLite datasource), on the scratch object the fixtures prescribe (sku unique:true, gsku unique:'global', nick): acceptance[0], negative[0] and negative[1] (a colliding create is 409 UNIQUE_VIOLATION; the developerMessage names the object by its label and says 'No record was written.'; on the tenant-scoped composite index the error is the generic 'A record with this value already exists', on the single-column global index it names the field by its label ('Global SKU'), the API key 'gsku' only on `field`; no driver prefix, statement or bound value; the colliding row is not stored), acceptance[1] (two rows with no sku both land), acceptance[2] (a PATCH onto a taken value is 409 and the row keeps its value; a PATCH leaving sku alone, or re-sending its own value, is 200) and acceptance[3] with negative[2] at the REST door that stamps one value onto two rows, POST /data/OBJECT/updateMany with options.atomic (its per-row report refuses both, ROLLED_BACK then UNIQUE_VIOLATION, and both rows re-read unchanged; REST has no predicate-update door, so the engine's predicate updateMany stays the memory pin's). packages/drivers/driver-memory/src/memory-unique-constraint.test.ts — acceptance[4]'s memory-family legs (the \"[#13197] scoping matches driver-sql's ADR-0120 D1/D3 rule\" describe), acceptance[5] ('the refusal is recognisable to the ENGINE, so the autonumber resync converges'), negative[2]'s predicate half ('updateMany refuses BEFORE mutating anything — no half-applied batch') and negative[3]'s memory-family half, at the memory driver; + packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts + packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts — acceptance[4]'s SQL-family legs and negative[3]'s SQL-family half, at SqlDriver over better-sqlite3, through a test-local createAsApi mapping; driver-level evidence beside acceptance[0]–[3], never their pass"
46114611
},
46124612
"source": [
46134613
"packages/drivers/driver-memory/src/memory-unique-constraint.ts#scope (#13197/#13249, commit 56c093c — single judgment point; scope table read off driver-sql, arm for arm)",
@@ -4683,6 +4683,12 @@
46834683
"date": "2026-10-27",
46844684
"change": "automated.ref names the dogfood pin that scores acceptance[0]–[3] and negative[0]–[2] over REST on the SQL family (#26573 slice 3); 'NO pin drives a REST door' no longer holds. Measured: the global index names the field in the user-facing error by its API key (gsku) and in developerMessage by its label; the composite tenant index names none. acceptance[3]'s REST door is the atomic updateMany records batch, since REST has no predicate update. kind unit → dogfood. No clause changed",
46854685
"ref": "#26573"
4686+
},
4687+
{
4688+
"revision": 12,
4689+
"date": "2026-10-27",
4690+
"change": "acceptance[0].clause: the user-facing `error` NAMES THE FIELD BY ITS LABEL ('A record with this LABEL already exists') where the driver reports a single offending column and the field has a label, and is generic otherwise, where it named the field by its API key ('A record with this gsku already exists') while developerMessage named it by label. The engine's DuplicateRecordError carries the label it resolves for its sentence as fieldLabel (packages/objectql/src/duplicate-record-error.ts, in the writer's locale), and the REST classification names the field by it (packages/types/src/data-error-classification.ts); `field` keeps the API key. automated.ref's sentence follows it, and the dogfood pin's exact body reads 'A record with this Global SKU already exists'. Status 409, code UNIQUE_VIOLATION, developerMessage and the composite-index generic sentence are unchanged",
4691+
"ref": "#26867"
46864692
}
46874693
]
46884694
},

‎packages/core/src/utils/import-runner-error-sanitize.test.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
* face 1 — `uniqueViolationColumn` (`@objectstack/types`), the column answer;
1919
* face 2 — `sanitizeRowError` (this package), the sentence the importer shows.
2020
*
21+
* [#26867] Face 2 no longer names the column for ANY conflict: the column is
22+
* the field's API key, and the end user's sentence names a field only by its
23+
* label, which a driver's text never carries. So a sample whose face 1 answers
24+
* a column still expects the unnamed sentence on face 2 — the two faces now
25+
* disagree on purpose, and the table keeps both so neither drifts.
26+
*
2127
* A dialect that regresses on either goes red here, and teaching one face a
2228
* dialect without the other cannot go green. The table lives in this package
2329
* for the same reason #6250's does: `@objectstack/types` cannot import
@@ -51,7 +57,7 @@ interface DialectSample {
5157
readonly conflict: boolean;
5258
/** Face 1: the column `uniqueViolationColumn` must name, or `undefined`. */
5359
readonly column: string | undefined;
54-
/** Face 2: the exact sentence the importer must show. */
60+
/** Face 2: the exact sentence the importer must show — never the column (#26867). */
5561
readonly sanitized: string;
5662
}
5763

@@ -75,15 +81,15 @@ const DIALECT_SAMPLES: readonly DialectSample[] = [
7581
"('a@b.com', 'zhoujunyi', '13800000000') - UNIQUE constraint failed: sys_user.phone_number",
7682
conflict: true,
7783
column: 'phone_number',
78-
sanitized: 'A record with this phone_number already exists.',
84+
sanitized: UNNAMED_CONFLICT,
7985
},
8086
{
8187
dialect: 'sqlite',
8288
label: 'UNIQUE constraint failed — bare driver message',
8389
message: 'UNIQUE constraint failed: sys_user.email',
8490
conflict: true,
8591
column: 'email',
86-
sanitized: 'A record with this email already exists.',
92+
sanitized: UNNAMED_CONFLICT,
8793
},
8894
// Composite: no single offending column, so naming the first would point the
8995
// form at `tenant_id` when what was typed twice is `email`.
@@ -182,7 +188,7 @@ const DIALECT_SAMPLES: readonly DialectSample[] = [
182188
`Detail: Key (email)=(${OFFENDING_VALUE}) already exists.`,
183189
conflict: true,
184190
column: 'email',
185-
sanitized: 'A record with this email already exists.',
191+
sanitized: UNNAMED_CONFLICT,
186192
},
187193
{
188194
dialect: 'postgres',
@@ -192,7 +198,7 @@ const DIALECT_SAMPLES: readonly DialectSample[] = [
192198
`Detail: Key ("emailAddress")=(${OFFENDING_VALUE}) already exists.`,
193199
conflict: true,
194200
column: 'emailAddress',
195-
sanitized: 'A record with this emailAddress already exists.',
201+
sanitized: UNNAMED_CONFLICT,
196202
},
197203
// No DETAIL line: the only identifier present is the constraint (index) name.
198204
// Before #6544 this fell through to the SQL backstop and echoed the driver's

‎packages/core/src/utils/import-runner.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import type { CreateDataRequest, FindDataRequest, GetMetaItemRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api';
1313
import { isMatchKeySets } from '@objectstack/spec/api';
1414
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js';
15-
import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin, isSandboxOwnFault } from '@objectstack/types';
15+
import { isUniqueViolationError, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin, isSandboxOwnFault } from '@objectstack/types';
1616

1717
/**
1818
* import-runner — the shared row-processing core for bulk import.
@@ -327,8 +327,8 @@ function bareColumn(raw: string): string {
327327
}
328328

329329
/**
330-
* The sentence used when the row conflicts but no column is determinable
331-
* (#6544). Deliberately the same wording `mapDataError` puts in the 409
330+
* The sentence used when the row conflicts and no field LABEL is in hand
331+
* (#6544, #26867). Deliberately the same wording `mapDataError` puts in the 409
332332
* `UNIQUE_VIOLATION` body, so the importer and the API say one thing about one
333333
* condition rather than two.
334334
*/
@@ -357,18 +357,21 @@ const UNNAMED_CONFLICT = 'A record with this value already exists.';
357357
* index name, so **MySQL rows no longer name a column** — they used to name
358358
* the index (`for key 'idx_email_unique'`) as if it were one, pointing the
359359
* user at a field that does not exist. See that function's doc comment.
360+
*
361+
* [#26867] A unique conflict no longer names the column here at all. What the
362+
* driver's text names is the column — the field's API KEY — and this function
363+
* holds only that text, never the object's schema, so it has no label to name
364+
* the field by; the end user's sentence names a field only by its label (the
365+
* create door's, adopted whole by {@link toFailedResult} from the engine's
366+
* envelope). The row's `field` keeps the API key for programs.
360367
*/
361368
export function sanitizeRowError(raw: unknown): string {
362369
const msg = typeof raw === 'string' ? raw.trim() : '';
363370
if (!msg) return 'Row failed';
364371

365-
// UNIQUE — surface the offending column when the dialect determinably named
366-
// one (it maps to a user-facing import column, so naming it is helpful, not a
367-
// schema leak); otherwise say so generically rather than guess.
368-
if (isUniqueViolationError(msg)) {
369-
const column = uniqueViolationColumn(msg);
370-
return column ? `A record with this ${column} already exists.` : UNNAMED_CONFLICT;
371-
}
372+
// UNIQUE — the generic sentence: the column the driver names is an API key,
373+
// not a label, so it is not the end user's word for the field.
374+
if (isUniqueViolationError(msg)) return UNNAMED_CONFLICT;
372375

373376
// NOT NULL — a required value is missing.
374377
const notNull = /not null constraint failed:\s*([^\s,)]+)/i.exec(msg);

0 commit comments

Comments
 (0)