Skip to content

Commit 6b656bf

Browse files
fix(objectql): a unique refusal's user-facing sentence names the object and field by label (#25021)
Fixes #24951 Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves) ## What changed A unique refusal's user-facing sentence (`DuplicateRecordError.message`) now names the object and the field by their declared `label`. Every door keeps that message as thrown, so a refused record save and a screen flow both show it to the end user. - Before: `Duplicate record refused on 'crm_account': a unique constraint on 'name' already holds this value. No record was written.` - After: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` - Where no label is declared, the sentence uses the API name, so it is byte-identical to before. - The opening `Duplicate record refused on '` and the closing `No record was written.` do not move. The sentence still starts with `Duplicate`, not with a SQL verb, so the importer's `sanitizeRowError` backstop leaves it alone. **Where the labels come from.** `DuplicateRecordError` is built in one place, `envelopeUniqueViolation`. It is called from five sites in `engine.ts`, all inside engine methods with the registry in reach: - the single-row insert door and its retry (`createWithAutonumberResync`); - the batch insert door; - update by id; - update by predicate (`updateMany`). Each site now passes `this.getSchema(object)`. `envelopeUniqueViolation` is module-internal (it is not in the package's `index.ts`), so its new third argument is not a public signature change. The labels reach the class through one **optional fourth constructor argument**, `{ object?, field? }`. Callers that pass three arguments, including every test and fake in other packages, get the API-name sentence. No new export. **Diagnostics keep the API names.** `object` and `field` still carry them. `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. The driver refused …`. Before this PR, `developerMessage` named neither (see Deviations). **Not moved:** the codes (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. ## Census, read before the edit - **Label reach:** all five `envelopeUniqueViolation` call sites are listed above. There is no upsert or bulk site in `packages/objectql` beyond these. `new DuplicateRecordError` appears in source only inside `envelopeUniqueViolation`; every other occurrence is a test. - **Pins of the sentence** (`git grep` on both phrases across every package, `qa/`, `docs/qa/platform-checklist/`, `content/docs/`; CHANGELOGs excluded): - **Exact-sentence pins on unlabelled fixtures. They stay green because of the fallback:** - `rest`: `rest-duplicate-record-arm.test.ts`, `error-response-structured-arm-door-parity.test.ts`; - `mcp`: `mcp-unique-violation-wire-code.test.ts`; - `plugin-hono-server`: `handler-throw-declared-envelope.test.ts`; - `metadata-protocol`: `protocol.batch-row-http-status.test.ts`; - `runtime`: `batch-row-driver-text-real-driver.integration.test.ts`. - **Stable-part pins:** `runtime`'s `seed-loader-driver-text-real-driver.integration.test.ts` (`dt_acct`, unlabelled) stayed green. - **One pin turned red, on a real engine with a labelled object:** `cli`'s `meta-stored-legacy-organization.integration.test.ts` matched `/Duplicate record refused on 'sys_metadata'/`, and `sys_metadata` is labelled `System Metadata`. It moved (see below). - **Doc mentions:** `content/docs/protocol/kernel/error-handling.mdx` and `docs/qa/platform-checklist/areas/records-forms.json` described the sentence with an API name. Both are updated. - **A second producer, not touched:** `driver-sql`'s `refuseUpsertConflictOutsideWrittenOrganization` (`sql-driver.ts`) builds a copy of the same sentence with the object's API name. It has no registry and is outside this card's fence (see Acceptance notes). ## Moved pins and docs, by lane - `packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts` (**`domain:cli`**). The pin now matches the stable opening `^Duplicate record refused on '` and asserts `error.object === 'sys_metadata'`, so it no longer restates the label. - `content/docs/protocol/kernel/error-handling.mdx` (**`domain:devx`**). The example reads `'Account'` (the object's label). - `docs/qa/platform-checklist/areas/records-forms.json` (**`domain:devx`**). The `qa_unique` clause now says the sentence names the object by its declared label, and by its API name only where none is declared. The item `records-forms.field-unique-enforcement` moves from revision 8 to 9, with one `history` row (ref `#25021`), as the ledger rule asks of a clause change (`domain:devx`, 6305842305). ## Verification record Base: `origin/main` `7532ea3c08`. `origin/main` was merged at `697e25ef6` (the incoming commits touch no `objectql` file). After the merge, the `objectql` dependency closure, the `objectql` build and the `objectql` typecheck were re-run: exit 0. - **New pins:** `packages/objectql/src/engine-duplicate-record-label-sentence.test.ts`. They drive the real write doors (single insert, batch insert, update by id, update by predicate) on a registry double: - the label sentence for a labelled object and field; - the API-name fallback for an unlabelled one; - the field-less variant (the dialect named only an index); - `developerMessage` naming the API names. Result: 17/17 passed. - **Ablation:** I reverted `duplicate-record-error.ts` and `engine.ts` to the base. The marker counts went from 2/5 to 0/0. **13 failed, 4 passed.** The 4 that passed are the fallback pins, which describe the old behaviour. I then restored both files with `git checkout HEAD --`. Each file's blob hash equals the one at `HEAD` (`8830e86e…`, `247b9130…`), `git diff HEAD` is empty, and the re-run passed 17/17. - **`@objectstack/objectql`:** `typecheck` exit 0, including `check:test-typecheck` over the new file. Full `test` exit 0: 452 files, 8524 tests. - **Dependent full suites, each exit 0:** - `rest`: 307 files; - `runtime`: 432 files; - `mcp`: 40 files; - `plugin-hono-server`: 35 files; - `metadata-protocol`: 251 files, 3 skipped; - `service-automation`: 197 files, 2605 tests. - **`cli`:** - `--project unit`: 315 files green. Two files first failed only because `packages/cli` had not been built; they passed after the build. - `--project integration`: 165 files green and 1 skipped. Twelve files first failed only for the same build prerequisite and passed after the build. The moved pin passes 7/7. - **`pnpm --filter @objectstack/core test:repo`:** exit 0, 6 files, 82 tests. - **Gates:** I ran every command printed by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `697e25ef6` (104 commands). - 101 exited 0 on the first run. - 3 exited 3 (PREREQUISITE NOT MET: unbuilt packages): `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage`. After a full `turbo run build`, all three exited 0. - `--ran` reconciliation: `104 derived famil(ies) accounted for — 104 run, 0 NOT-MEASURED (a DERIVED zero — all 104 recorded an exit code and none of them is 3)`. ## Deviations - `developerMessage` gained an opening clause naming the API names. The card and the dispatch say it "keeps" them, but on `main` it named neither the object nor the field. Without that clause, the API names would only be on `object` and `field` once `message` uses labels. The seat re-declared the claim's `Clause-②` line in 6305353681 (fix round 1), and the changeset and this body carry the re-declared line. - `DuplicateRecordError`'s constructor takes an optional trailing argument (`labels`). The class is exported, so this is an additive, optional signature widening. No export was added. ## Acceptance notes - `driver-sql`'s upsert refusal outside the written organization (`refuseUpsertConflictOutsideWrittenOrganization`) still names the object by API name. It is a driver-side copy of the sentence and has no registry in reach. No engine write door calls `driver.upsert`: its callers, such as the lifecycle service's `cold.upsert`, a seed or a connector, receive the error exactly as the driver throws it. So its text is unchanged here. No carrier. - On the REST data routes, the wire `developerMessage` is the engine's `message`, so it now names labels. The body's `object` and `field` keys still carry the API names, and the curated `error` text is unchanged. - The sentence stays English. A localized entry would go in `packages/spec`'s operation-message catalog (`domain:spec`) and is not part of this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01X8RKtAGLpTgi5BmpDr2xeo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b3830c commit 6b656bf

7 files changed

Lines changed: 244 additions & 15 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a unique refusal's user-facing sentence names the object and the field by label
6+
7+
Clause-②: no (text only: the user-facing sentence names the object and field by label, and `developerMessage` opens with the API names; no code, status, outcome or export moves)
8+
9+
`DuplicateRecordError.message` is the sentence every door keeps as thrown, so a record save and a screen flow refused by a unique constraint show it to the end user word for word. It named the object, and the field when the driver reported one, by API name: `Duplicate record refused on 'crm_account': …`.
10+
11+
- **The sentence now uses the declared `label`.** The engine looks up the object's and the field's labels in its registry on every write door that raises the refusal: insert (single and batch), and update by id and by predicate. Example: `Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. No record was written.` Where no label is declared, the sentence uses the API name, exactly as before. The opening words and `No record was written.` are unchanged.
12+
- **The API names stay available for diagnostics.** They are still on `object` and `field`, and `developerMessage` now opens with them: `Duplicate record refused on object 'crm_account', field 'name'. …`.
13+
- **Unchanged:** the code (`DUPLICATE_RECORD` in the engine, `UNIQUE_VIOLATION` on every wire route), the 409 status and the outcome. `DuplicateRecordError`'s constructor takes an optional fourth argument with the labels. Callers that pass three arguments get the API-name sentence.
14+
- **For HTTP clients:** on the REST data routes the curated `error` text is unchanged. The wire `developerMessage` (which carries the engine's sentence) now names the labels, and the body's `object` and `field` keys still carry the API names.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ class — keeps its own code.
265265
(on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the
266266
thrown one and can differ from the flat door's `error`: an unregistered object reads
267267
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there, and a repeated
268-
unique value reads the engine's `Duplicate record refused on 'account': …` here, the same
268+
unique value reads the engine's `Duplicate record refused on 'Account': …` (the object's label) here, the same
269269
sentence `details.developerMessage` carries, where the flat door's `error` reads
270270
`A record with this email already exists`.
271271

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4267,7 +4267,7 @@
42674267
"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",
42684268
"since": "v17",
42694269
"status": "active",
4270-
"revision": 8,
4270+
"revision": 9,
42714271
"priority": "P1",
42724272
"surface": "api",
42734273
"personas": [
@@ -4296,7 +4296,7 @@
42964296
],
42974297
"acceptance": [
42984298
{
4299-
"clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT ('Duplicate record refused on 'qa_unique': …') 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",
4299+
"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",
43004300
"oracle": "api",
43014301
"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",
43024302
"evidence": "refusal response + before/after counts"
@@ -4401,6 +4401,12 @@
44014401
"date": "2026-10-21",
44024402
"change": "Respelled — fixtures.requires[0]: 'an isolated dev boot on the stock SQLite store' → 'a dev boot on its own port and DB, on the stock SQLite store'. PR #25003's out-of-scope finding (round 17 of wave anchor #24579; ACCEPT 6304389370, landing record 6304641541): 22 non-history texts in 17 items named the own-port boot or its DB with 'isolated' in other words, the phrasing round 12 found a runner reading as the `isolated` tenancy posture (OS_TENANCY_POSTURE=isolated). The new spelling names the boot or the DB by what it is, the run's own port and DB (RUNNER.md's trap-table counter: own port/DB, skill §0), and names no posture. Read at base 93e6c1a5f8. No clause changed",
44034403
"ref": "#25018"
4404+
},
4405+
{
4406+
"revision": 9,
4407+
"date": "2026-10-21",
4408+
"change": "acceptance[0].clause: the developerMessage now 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, where it named the object by its API name ('Duplicate record refused on 'qa_unique': …'). The engine's unique-refusal sentence (packages/objectql/src/duplicate-record-error.ts) names the object and field by label since #24951; the REST data route carries that sentence on developerMessage. Status 409, code UNIQUE_VIOLATION, the generic user-facing `error` and 'No record was written.' are unchanged",
4409+
"ref": "#25021"
44044410
}
44054411
]
44064412
},

‎packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,9 @@ describe('the ceremony\'s template-promotion save on a v17-shape database', () =
255255
const db = caseDb();
256256
const before = (await rawRows(db)).filter((r) => r.type === 'email_template');
257257
const error = await saveTemplateOnCeremonyBoot(db, 'Environment welcome');
258-
expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/);
258+
// The sentence names the object by label; the API name rides on `object`.
259+
expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/);
260+
expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata');
259261
expect((await rawRows(db)).filter((r) => r.type === 'email_template')).toEqual(before);
260262
}, CASE_TIMEOUT_MS);
261263

@@ -316,7 +318,9 @@ describe('[ADR-0131 D6/D7] the ceremony boot\'s readers ignore a legacy organiza
316318
// organization's lock: the save reached the store, where the re-keyed
317319
// unique index refuses an environment row beside the organization's row of
318320
// the same key — the refusal the template pin above measures.
319-
expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/);
321+
// The sentence names the object by label; the API name rides on `object`.
322+
expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/);
323+
expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata');
320324
expect(String((error as Error | null)?.message ?? error)).not.toMatch(/lock/i);
321325
const org = (await rawRows(db)).find((r) => r.id === 'org_locked_template')!;
322326
expect(org.metadata).toEqual(tpl(LOCKED_TEMPLATE, 'Org A locked', { _lock: 'full' }));

‎packages/objectql/src/duplicate-record-error.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/type
6565
* engine's `DELETE_RESTRICTED` refusal splits it: `message` is the sentence
6666
* a user-facing surface renders, `developerMessage` is the one addressed to
6767
* the application author.
68+
* - **Labels in `message`, API names in `developerMessage`** — every door keeps
69+
* `message` as thrown, so a refused record save and a screen flow show it to
70+
* the end user verbatim. [#24951] It therefore names the object and the field
71+
* by their declared `label` (the engine resolves them from its registry at
72+
* the write), falling back to the API name only where no label is declared —
73+
* the same "object label" naming ruling B of #23556 / #23594 set for the
74+
* approval notifications. The API names stay on `object` / `field` and open
75+
* `developerMessage`, for diagnostics.
6876
*
6977
* ## ⛔ What this deliberately does NOT do
7078
*
@@ -79,6 +87,9 @@ export const DUPLICATE_RECORD_CODE = 'DUPLICATE_RECORD' as const;
7987
/** The conflict status the engine's sibling 409 refusals declare. */
8088
const DUPLICATE_RECORD_STATUS = 409 as const;
8189

90+
/** The declared labels of the refused write's object and conflicting field. */
91+
type DuplicateRecordLabels = { readonly object?: string; readonly field?: string };
92+
8293
export class DuplicateRecordError extends Error {
8394
readonly code = DUPLICATE_RECORD_CODE;
8495
readonly status = DUPLICATE_RECORD_STATUS;
@@ -103,12 +114,16 @@ export class DuplicateRecordError extends Error {
103114
cause: unknown,
104115
/** The conflicting column, when the dialect determinably named one. */
105116
public readonly field?: string,
117+
/** The declared labels the user-facing `message` names them by; absent ⇒ the API name. */
118+
labels?: DuplicateRecordLabels,
106119
) {
107-
super(buildDuplicateMessage(object, field));
120+
super(buildDuplicateMessage(labels?.object || object, field && (labels?.field || field)));
108121
this.name = 'DuplicateRecordError';
109122
this.cause = cause;
110123
this.developerMessage =
111-
`The driver refused this write as a unique-constraint violation. Its own error is attached ` +
124+
`Duplicate record refused on object '${object}'` +
125+
(field ? `, field '${field}'` : '') +
126+
`. The driver refused this write as a unique-constraint violation. Its own error is attached ` +
112127
`as \`cause\`. Branch on the platform code (ADR-0112), never on a dialect's code or message, ` +
113128
`so the handling survives a change of store: over the HTTP API this refusal is reported as ` +
114129
`\`code === 'UNIQUE_VIOLATION'\` on every route, whole-request and per-row alike; inside the ` +
@@ -118,7 +133,8 @@ export class DuplicateRecordError extends Error {
118133
}
119134

120135
/**
121-
* The user-facing sentence.
136+
* The user-facing sentence, naming the object and the field by label (or by API
137+
* name where none is declared — the caller has already chosen).
122138
*
123139
* ⛔ It must not begin with a SQL verb. `@objectstack/rest`'s importer runs
124140
* every row error through `sanitizeRowError`, whose SQL backstop replaces any
@@ -151,8 +167,23 @@ function buildDuplicateMessage(object: string, field?: string): string {
151167
* seam that wraps a value another seam already wrapped cannot bury the driver's
152168
* error one `cause` step deeper.
153169
*/
154-
export function envelopeUniqueViolation(error: unknown, object: string): unknown {
170+
export function envelopeUniqueViolation(error: unknown, object: string, schema?: unknown): unknown {
155171
if (error instanceof DuplicateRecordError) return error;
156172
if (!isUniqueViolationError(error)) return error;
157-
return new DuplicateRecordError(object, error, uniqueViolationColumn(error));
173+
const field = uniqueViolationColumn(error);
174+
return new DuplicateRecordError(object, error, field, declaredLabels(schema, field));
175+
}
176+
177+
/**
178+
* The declared `label` of the object and of the conflicting field, read off the
179+
* registry's schema for the written object. A label that is absent, empty or not
180+
* a string is simply not returned, so the sentence falls back to the API name.
181+
*/
182+
function declaredLabels(schema: unknown, field: string | undefined): DuplicateRecordLabels {
183+
const s = schema as { label?: unknown; fields?: Record<string, { label?: unknown } | undefined> } | undefined;
184+
const text = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined);
185+
return {
186+
object: text(s?.label),
187+
field: field ? text(s?.fields?.[field]?.label) : undefined,
188+
};
158189
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24951] A unique refusal's user-facing sentence names the object and the
5+
* field by their declared `label`.
6+
*
7+
* Every door keeps `DuplicateRecordError.message` as thrown, so a refused record
8+
* save and a screen flow both show it to the end user verbatim — and it used to
9+
* read `Duplicate record refused on 'crm_account': …`, an API name the user
10+
* cannot act on. The engine now resolves the labels from its registry at the
11+
* write, falling back to the API name only where none is declared; the API
12+
* names stay on `object` / `field` and open `developerMessage`.
13+
*
14+
* Asserted through the real write doors (`insert` single and batch, `update`
15+
* by id and by predicate) on a registry double, so the pin covers the label lookup at each
16+
* call site and not only the sentence builder.
17+
*/
18+
19+
import { describe, it, expect, beforeEach, vi } from 'vitest';
20+
import { ObjectQL } from './engine';
21+
import { DuplicateRecordError } from './duplicate-record-error';
22+
import { SchemaRegistry } from './registry';
23+
import type { IDataDriver } from '@objectstack/spec/contracts';
24+
25+
vi.mock('./registry', async () => {
26+
const { createRegistryModuleMock } = await import('./registry-module-mock.js');
27+
return createRegistryModuleMock();
28+
});
29+
30+
const registryDouble = SchemaRegistry as unknown as { getObject: ReturnType<typeof vi.fn> };
31+
32+
/** better-sqlite3: names the column, so the envelope carries `field`. */
33+
const namedColumn = () =>
34+
Object.assign(
35+
new Error('insert into `crm_account` (`id`, `name`) values (?, ?) - UNIQUE constraint failed: crm_account.name'),
36+
{ code: 'SQLITE_CONSTRAINT_UNIQUE' },
37+
);
38+
39+
/** mysql2: names the INDEX only, so the envelope carries no `field`. */
40+
const indexOnly = () =>
41+
Object.assign(new Error("Duplicate entry 'Acme' for key 'idx_account_name'"), { code: 'ER_DUP_ENTRY', errno: 1062 });
42+
43+
const LABELLED = {
44+
name: 'crm_account',
45+
label: 'Account',
46+
fields: { name: { type: 'text', label: 'Account Name' } },
47+
};
48+
49+
const UNLABELLED = {
50+
name: 'crm_account',
51+
fields: { name: { type: 'text' } },
52+
};
53+
54+
function makeRig(schema: unknown, refuse: () => unknown) {
55+
registryDouble.getObject.mockReturnValue(schema);
56+
const driver: any = {
57+
name: 'fake',
58+
version: '0.0.0',
59+
supports: {},
60+
connect: vi.fn().mockResolvedValue(undefined),
61+
disconnect: vi.fn().mockResolvedValue(undefined),
62+
checkHealth: vi.fn().mockResolvedValue(true),
63+
execute: vi.fn(),
64+
find: vi.fn(async () => []),
65+
findOne: vi.fn(async () => ({ id: 'r1', name: 'Old' })),
66+
create: vi.fn(async () => {
67+
throw refuse();
68+
}),
69+
bulkCreate: vi.fn(async () => {
70+
throw refuse();
71+
}),
72+
update: vi.fn(async () => {
73+
throw refuse();
74+
}),
75+
updateMany: vi.fn(async () => {
76+
throw refuse();
77+
}),
78+
delete: vi.fn(),
79+
count: vi.fn(),
80+
};
81+
const engine = new ObjectQL();
82+
engine.registerDriver(driver as IDataDriver, true);
83+
return engine;
84+
}
85+
86+
async function refusalOf(run: () => Promise<unknown>): Promise<any> {
87+
return run().then(
88+
() => {
89+
throw new Error('expected the write to be refused');
90+
},
91+
(e) => e as any,
92+
);
93+
}
94+
95+
const DOORS: Array<[string, (engine: ObjectQL) => Promise<unknown>]> = [
96+
['insert', (engine) => engine.insert('crm_account', { name: 'Acme' })],
97+
['batch insert', (engine) => engine.insert('crm_account', [{ name: 'Acme' }, { name: 'Acme' }])],
98+
['update by id', (engine) => engine.update('crm_account', { id: 'r1', name: 'Acme' })],
99+
['update by predicate', (engine) => engine.update('crm_account', { name: 'Acme' }, { where: { name: 'Old' }, multi: true } as any)],
100+
];
101+
102+
describe('a unique refusal names the object and field by label (#24951)', () => {
103+
beforeEach(() => {
104+
vi.clearAllMocks();
105+
});
106+
107+
describe.each(DOORS)('on %s', (_door, write) => {
108+
it('a labelled object and field: the sentence names both labels, and no API name', async () => {
109+
const engine = makeRig(LABELLED, namedColumn);
110+
await engine.init();
111+
112+
const failure = await refusalOf(() => write(engine));
113+
114+
expect(failure).toBeInstanceOf(DuplicateRecordError);
115+
expect(failure.code).toBe('DUPLICATE_RECORD');
116+
expect(failure.status).toBe(409);
117+
expect(failure.message).toBe(
118+
"Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. " +
119+
'No record was written.',
120+
);
121+
expect(failure.message).not.toContain('crm_account');
122+
// The API names stay where diagnostics read them.
123+
expect(failure.object).toBe('crm_account');
124+
expect(failure.field).toBe('name');
125+
});
126+
127+
it('an unlabelled object and field: the sentence falls back to the API names', async () => {
128+
const engine = makeRig(UNLABELLED, namedColumn);
129+
await engine.init();
130+
131+
const failure = await refusalOf(() => write(engine));
132+
133+
expect(failure.code).toBe('DUPLICATE_RECORD');
134+
expect(failure.status).toBe(409);
135+
expect(failure.message).toBe(
136+
"Duplicate record refused on 'crm_account': a unique constraint on 'name' already holds this value. " +
137+
'No record was written.',
138+
);
139+
});
140+
141+
it('no determinable field: the field-less sentence, naming the object by label', async () => {
142+
const engine = makeRig(LABELLED, indexOnly);
143+
await engine.init();
144+
145+
const failure = await refusalOf(() => write(engine));
146+
147+
expect(failure.code).toBe('DUPLICATE_RECORD');
148+
expect(failure.status).toBe(409);
149+
expect(failure.field).toBeUndefined();
150+
expect(failure.message).toBe(
151+
"Duplicate record refused on 'Account': a unique constraint already holds these values. No record was written.",
152+
);
153+
});
154+
155+
it('`developerMessage` names the API names, never the labels', async () => {
156+
const engine = makeRig(LABELLED, namedColumn);
157+
await engine.init();
158+
159+
const failure = await refusalOf(() => write(engine));
160+
161+
expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account', field 'name'\. /);
162+
expect(failure.developerMessage).not.toContain('Account Name');
163+
});
164+
});
165+
166+
it('a field-less refusal\'s `developerMessage` names the object API name alone', async () => {
167+
const engine = makeRig(LABELLED, indexOnly);
168+
await engine.init();
169+
170+
const failure = await refusalOf(() => engine.insert('crm_account', { name: 'Acme' }));
171+
172+
expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account'\. /);
173+
});
174+
});

0 commit comments

Comments
 (0)