Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/24951-duplicate-message-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/objectql': patch
---

fix(objectql): a unique refusal's user-facing sentence names the object and the field by label

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)

`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': …`.

- **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.
- **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'. …`.
- **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.
- **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.
2 changes: 1 addition & 1 deletion content/docs/protocol/kernel/error-handling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ class — keeps its own code.
(on `DELETE_RESTRICTED`, `CONCURRENT_UPDATE` and `VALIDATION_FAILED`). The `message` is the
thrown one and can differ from the flat door's `error`: an unregistered object reads
`Object 'ghost' not found` here and `Object 'ghost' is not registered` there, and a repeated
unique value reads the engine's `Duplicate record refused on 'account': …` here, the same
unique value reads the engine's `Duplicate record refused on 'Account': …` (the object's label) here, the same
sentence `details.developerMessage` carries, where the flat door's `error` reads
`A record with this email already exists`.

Expand Down
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/records-forms.json
Original file line number Diff line number Diff line change
Expand Up @@ -4267,7 +4267,7 @@
"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",
"since": "v17",
"status": "active",
"revision": 8,
"revision": 9,
"priority": "P1",
"surface": "api",
"personas": [
Expand Down Expand Up @@ -4296,7 +4296,7 @@
],
"acceptance": [
{
"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",
"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",
"oracle": "api",
"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",
"evidence": "refusal response + before/after counts"
Expand Down Expand Up @@ -4401,6 +4401,12 @@
"date": "2026-10-21",
"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",
"ref": "#25018"
},
{
"revision": 9,
"date": "2026-10-21",
"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",
"ref": "#25021"
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ describe('the ceremony\'s template-promotion save on a v17-shape database', () =
const db = caseDb();
const before = (await rawRows(db)).filter((r) => r.type === 'email_template');
const error = await saveTemplateOnCeremonyBoot(db, 'Environment welcome');
expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/);
// The sentence names the object by label; the API name rides on `object`.
expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/);
expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata');
expect((await rawRows(db)).filter((r) => r.type === 'email_template')).toEqual(before);
}, CASE_TIMEOUT_MS);

Expand Down Expand Up @@ -316,7 +318,9 @@ describe('[ADR-0131 D6/D7] the ceremony boot\'s readers ignore a legacy organiza
// organization's lock: the save reached the store, where the re-keyed
// unique index refuses an environment row beside the organization's row of
// the same key — the refusal the template pin above measures.
expect(String((error as Error | null)?.message ?? error)).toMatch(/Duplicate record refused on 'sys_metadata'/);
// The sentence names the object by label; the API name rides on `object`.
expect(String((error as Error | null)?.message ?? error)).toMatch(/^Duplicate record refused on '/);
expect((error as { object?: unknown } | null)?.object).toBe('sys_metadata');
expect(String((error as Error | null)?.message ?? error)).not.toMatch(/lock/i);
const org = (await rawRows(db)).find((r) => r.id === 'org_locked_template')!;
expect(org.metadata).toEqual(tpl(LOCKED_TEMPLATE, 'Org A locked', { _lock: 'full' }));
Expand Down
41 changes: 36 additions & 5 deletions packages/objectql/src/duplicate-record-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/type
* engine's `DELETE_RESTRICTED` refusal splits it: `message` is the sentence
* a user-facing surface renders, `developerMessage` is the one addressed to
* the application author.
* - **Labels in `message`, API names in `developerMessage`** — every door keeps
* `message` as thrown, so a refused record save and a screen flow show it to
* the end user verbatim. [#24951] It therefore names the object and the field
* by their declared `label` (the engine resolves them from its registry at
* the write), falling back to the API name only where no label is declared —
* the same "object label" naming ruling B of #23556 / #23594 set for the
* approval notifications. The API names stay on `object` / `field` and open
* `developerMessage`, for diagnostics.
*
* ## ⛔ What this deliberately does NOT do
*
Expand All @@ -79,6 +87,9 @@ export const DUPLICATE_RECORD_CODE = 'DUPLICATE_RECORD' as const;
/** The conflict status the engine's sibling 409 refusals declare. */
const DUPLICATE_RECORD_STATUS = 409 as const;

/** The declared labels of the refused write's object and conflicting field. */
type DuplicateRecordLabels = { readonly object?: string; readonly field?: string };

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

/**
* The user-facing sentence.
* The user-facing sentence, naming the object and the field by label (or by API
* name where none is declared — the caller has already chosen).
*
* ⛔ It must not begin with a SQL verb. `@objectstack/rest`'s importer runs
* every row error through `sanitizeRowError`, whose SQL backstop replaces any
Expand Down Expand Up @@ -151,8 +167,23 @@ function buildDuplicateMessage(object: string, field?: string): string {
* seam that wraps a value another seam already wrapped cannot bury the driver's
* error one `cause` step deeper.
*/
export function envelopeUniqueViolation(error: unknown, object: string): unknown {
export function envelopeUniqueViolation(error: unknown, object: string, schema?: unknown): unknown {
if (error instanceof DuplicateRecordError) return error;
if (!isUniqueViolationError(error)) return error;
return new DuplicateRecordError(object, error, uniqueViolationColumn(error));
const field = uniqueViolationColumn(error);
return new DuplicateRecordError(object, error, field, declaredLabels(schema, field));
}

/**
* The declared `label` of the object and of the conflicting field, read off the
* registry's schema for the written object. A label that is absent, empty or not
* a string is simply not returned, so the sentence falls back to the API name.
*/
function declaredLabels(schema: unknown, field: string | undefined): DuplicateRecordLabels {
const s = schema as { label?: unknown; fields?: Record<string, { label?: unknown } | undefined> } | undefined;
const text = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined);
return {
object: text(s?.label),
field: field ? text(s?.fields?.[field]?.label) : undefined,
};
}
174 changes: 174 additions & 0 deletions packages/objectql/src/engine-duplicate-record-label-sentence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24951] A unique refusal's user-facing sentence names the object and the
* field by their declared `label`.
*
* Every door keeps `DuplicateRecordError.message` as thrown, so a refused record
* save and a screen flow both show it to the end user verbatim — and it used to
* read `Duplicate record refused on 'crm_account': …`, an API name the user
* cannot act on. The engine now resolves the labels from its registry at the
* write, falling back to the API name only where none is declared; the API
* names stay on `object` / `field` and open `developerMessage`.
*
* Asserted through the real write doors (`insert` single and batch, `update`
* by id and by predicate) on a registry double, so the pin covers the label lookup at each
* call site and not only the sentence builder.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ObjectQL } from './engine';
import { DuplicateRecordError } from './duplicate-record-error';
import { SchemaRegistry } from './registry';
import type { IDataDriver } from '@objectstack/spec/contracts';

vi.mock('./registry', async () => {
const { createRegistryModuleMock } = await import('./registry-module-mock.js');
return createRegistryModuleMock();
});

const registryDouble = SchemaRegistry as unknown as { getObject: ReturnType<typeof vi.fn> };

/** better-sqlite3: names the column, so the envelope carries `field`. */
const namedColumn = () =>
Object.assign(
new Error('insert into `crm_account` (`id`, `name`) values (?, ?) - UNIQUE constraint failed: crm_account.name'),
{ code: 'SQLITE_CONSTRAINT_UNIQUE' },
);

/** mysql2: names the INDEX only, so the envelope carries no `field`. */
const indexOnly = () =>
Object.assign(new Error("Duplicate entry 'Acme' for key 'idx_account_name'"), { code: 'ER_DUP_ENTRY', errno: 1062 });

const LABELLED = {
name: 'crm_account',
label: 'Account',
fields: { name: { type: 'text', label: 'Account Name' } },
};

const UNLABELLED = {
name: 'crm_account',
fields: { name: { type: 'text' } },
};

function makeRig(schema: unknown, refuse: () => unknown) {
registryDouble.getObject.mockReturnValue(schema);
const driver: any = {
name: 'fake',
version: '0.0.0',
supports: {},
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
checkHealth: vi.fn().mockResolvedValue(true),
execute: vi.fn(),
find: vi.fn(async () => []),
findOne: vi.fn(async () => ({ id: 'r1', name: 'Old' })),
create: vi.fn(async () => {
throw refuse();
}),
bulkCreate: vi.fn(async () => {
throw refuse();
}),
update: vi.fn(async () => {
throw refuse();
}),
updateMany: vi.fn(async () => {
throw refuse();
}),
delete: vi.fn(),
count: vi.fn(),
};
const engine = new ObjectQL();
engine.registerDriver(driver as IDataDriver, true);
return engine;
}

async function refusalOf(run: () => Promise<unknown>): Promise<any> {
return run().then(
() => {
throw new Error('expected the write to be refused');
},
(e) => e as any,
);
}

const DOORS: Array<[string, (engine: ObjectQL) => Promise<unknown>]> = [
['insert', (engine) => engine.insert('crm_account', { name: 'Acme' })],
['batch insert', (engine) => engine.insert('crm_account', [{ name: 'Acme' }, { name: 'Acme' }])],
['update by id', (engine) => engine.update('crm_account', { id: 'r1', name: 'Acme' })],
['update by predicate', (engine) => engine.update('crm_account', { name: 'Acme' }, { where: { name: 'Old' }, multi: true } as any)],
];

describe('a unique refusal names the object and field by label (#24951)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe.each(DOORS)('on %s', (_door, write) => {
it('a labelled object and field: the sentence names both labels, and no API name', async () => {
const engine = makeRig(LABELLED, namedColumn);
await engine.init();

const failure = await refusalOf(() => write(engine));

expect(failure).toBeInstanceOf(DuplicateRecordError);
expect(failure.code).toBe('DUPLICATE_RECORD');
expect(failure.status).toBe(409);
expect(failure.message).toBe(
"Duplicate record refused on 'Account': a unique constraint on 'Account Name' already holds this value. " +
'No record was written.',
);
expect(failure.message).not.toContain('crm_account');
// The API names stay where diagnostics read them.
expect(failure.object).toBe('crm_account');
expect(failure.field).toBe('name');
});

it('an unlabelled object and field: the sentence falls back to the API names', async () => {
const engine = makeRig(UNLABELLED, namedColumn);
await engine.init();

const failure = await refusalOf(() => write(engine));

expect(failure.code).toBe('DUPLICATE_RECORD');
expect(failure.status).toBe(409);
expect(failure.message).toBe(
"Duplicate record refused on 'crm_account': a unique constraint on 'name' already holds this value. " +
'No record was written.',
);
});

it('no determinable field: the field-less sentence, naming the object by label', async () => {
const engine = makeRig(LABELLED, indexOnly);
await engine.init();

const failure = await refusalOf(() => write(engine));

expect(failure.code).toBe('DUPLICATE_RECORD');
expect(failure.status).toBe(409);
expect(failure.field).toBeUndefined();
expect(failure.message).toBe(
"Duplicate record refused on 'Account': a unique constraint already holds these values. No record was written.",
);
});

it('`developerMessage` names the API names, never the labels', async () => {
const engine = makeRig(LABELLED, namedColumn);
await engine.init();

const failure = await refusalOf(() => write(engine));

expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account', field 'name'\. /);
expect(failure.developerMessage).not.toContain('Account Name');
});
});

it('a field-less refusal\'s `developerMessage` names the object API name alone', async () => {
const engine = makeRig(LABELLED, indexOnly);
await engine.init();

const failure = await refusalOf(() => engine.insert('crm_account', { name: 'Acme' }));

expect(failure.developerMessage).toMatch(/^Duplicate record refused on object 'crm_account'\. /);
});
});
Loading
Loading