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/33213-duplicate-refusal-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
'@objectstack/types': patch
'@objectstack/objectql': patch
---

fix(objectql): the duplicate refusal (`409 UNIQUE_VIOLATION`) now reads in the writer's language and names the fields of a composite unique index

Clause-②: yes (widening)
Metadata-protocol: none

A write refused by a unique constraint used to answer the fixed English sentence "A record with this value already exists" as the 409 body's `error`, whatever the request's `Accept-Language`, and never named the fields of a unique index over several columns, so the user could not tell which combination collided. The engine now composes that sentence in the writer's locale from the platform's operation message catalog, the catalog the `DELETE_RESTRICTED` refusal already uses, so a `zh-CN` request reads `已存在「工厂」、「牌号」、「成本周次」均相同的记录` where it read English. A composite key is named only when the columns the database reported match, as a set, a unique index the object declares, and only when every one of those fields has a label; otherwise the sentence names nothing, because a wrong field name is worse than none. The wire shape does not change: `code` stays `UNIQUE_VIOLATION`, the status stays 409, and `field` still names one column only when the database named exactly one. An English deployment reads the same sentences as before for a single field or none.

Three additions ride along. `BUILTIN_OPERATION_MESSAGES` (`@objectstack/spec/system`) gains `duplicate_record`, `duplicate_record_field` and `duplicate_record_fields` in every platform locale, so a deployment can override them with an `errors.duplicate_record*` translation. `@objectstack/types` exports `uniqueViolationColumns(error)`, the columns of a composite unique key the driver named, with the organization part set aside. `DuplicateRecordError` carries the composed sentence as `endUserMessage`. A MySQL refusal names an index and not its columns, so it still names no field.
158 changes: 149 additions & 9 deletions packages/objectql/src/duplicate-record-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { isUniqueViolationError, uniqueViolationField } from '@objectstack/types';
import { renderOperationMessage } from '@objectstack/spec/system';
import { isUniqueViolationError, uniqueViolationColumns, uniqueViolationField } from '@objectstack/types';
import { objectDisplayLabel } from './engine-validation-messages.js';
import { resolveFieldLabel, type ValidationMessageContext } from './validation/record-validator.js';

Expand Down Expand Up @@ -97,6 +98,32 @@ import { resolveFieldLabel, type ValidationMessageContext } from './validation/r
* a write made in `zh-CN` against a translated object reads its labels in
* `zh-CN`. With no message context (a bare-kernel caller) they are the
* declared labels, exactly as before.
* - **`endUserMessage`** — the end user's sentence, composed in the writer's
* locale from the platform's operation catalog (`duplicate_record*`,
* `renderOperationMessage` in `@objectstack/spec/system`, the catalog the
* `DELETE_RESTRICTED` refusal renders from), so a deployment's
* `errors.duplicate_record*` translation overrides it. The HTTP doors ship
* it as the 409 body's `error`; before, they composed a fixed English
* sentence there, so a `zh-CN` writer read English beside a Chinese
* object label. It names the conflicting field by label (one field), the
* fields of a composite unique index by their labels (see `uniqueFields`
* below), or nothing. ⛔ It is deliberately NOT spelled `userMessage`:
* that name is the ApiError contract's producer-marked channel, which every
* boundary promotes to a wire key of its own (`withDeclaredUserMessage`),
* and whether this refusal joins that channel is a contract decision this
* sentence does not make. The wire shape is unchanged: only the text of
* `error` moves.
* - **The fields of a composite unique index** — named in `endUserMessage`
* and in `message` ONLY when the columns the store named
* (`uniqueViolationColumns`, organization part set aside) match, as a set,
* the `fields` of a unique index the object DECLARES. Driver text alone is evidence, not a verdict: a
* `tenancy.tenantField` column prints beside the authored ones and cannot
* be told from them, so anything short of a declared match names nothing —
* the rule `field` follows, for the reason it does. `field` itself keeps
* its single-column contract and stays absent for a composite; the
* composite's API names open `developerMessage`. And the user's sentence
* names them only when every one has a label — never an API name, the
* rule `field_edit_denied` set for its list of fields.
*
* ## ⛔ What this deliberately does NOT do
*
Expand All @@ -111,8 +138,24 @@ 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 };
/**
* What the user-facing sentences name: the display labels of the refused
* write's object and conflicting field, the composite unique index's fields
* when one was matched, and the writer's locale to render the end user's
* sentence in.
*/
type DuplicateRecordLabels = {
readonly object?: string;
readonly field?: string;
/**
* The fields of the declared unique index the store's composite key matched,
* in the index's declared order, each with its display label (absent where
* the field has none). Absent when no declared index matched.
*/
readonly uniqueFields?: ReadonlyArray<{ readonly name: string; readonly label?: string }>;
/** The writer's locale and the deployment's translation hook; absent ⇒ `en`. */
readonly messages?: Pick<ValidationMessageContext, 'locale' | 'translate'>;
};

export class DuplicateRecordError extends Error {
readonly code = DUPLICATE_RECORD_CODE;
Expand All @@ -137,6 +180,12 @@ export class DuplicateRecordError extends Error {
* See the module header.
*/
readonly fieldLabel?: string;
/**
* The end user's sentence, in the writer's locale, from the platform's
* operation catalog — what the HTTP doors ship as the 409 body's `error`.
* See the module header, and on why it is not `userMessage`.
*/
readonly endUserMessage: string;

constructor(
/** The object the refused write targeted. */
Expand All @@ -147,13 +196,21 @@ export class DuplicateRecordError extends Error {
/** The display labels the user-facing `message` names them by; absent ⇒ the API name. */
labels?: DuplicateRecordLabels,
) {
super(buildDuplicateMessage(labels?.object || object, field && (labels?.field || field)));
const uniqueFields = field ? undefined : labels?.uniqueFields;
super(
buildDuplicateMessage(
labels?.object || object,
field ? [labels?.field || field] : uniqueFields?.map((f) => f.label || f.name),
),
);
this.name = 'DuplicateRecordError';
this.cause = cause;
if (field && labels?.field) this.fieldLabel = labels.field;
this.endUserMessage = buildEndUserMessage(field ? labels?.field : undefined, uniqueFields, labels?.messages);
this.developerMessage =
`Duplicate record refused on object '${object}'` +
(field ? `, field '${field}'` : '') +
(uniqueFields ? `, unique index fields ${uniqueFields.map((f) => `'${f.name}'`).join(', ')}` : '') +
`. 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 ` +
Expand All @@ -173,16 +230,45 @@ export class DuplicateRecordError extends Error {
* message opening "Insert on 'x' …" would be thrown away by a guard written
* against leaked driver statements. Measured, not guessed.
*/
function buildDuplicateMessage(object: string, field?: string): string {
function buildDuplicateMessage(object: string, fields?: readonly string[]): string {
return (
`Duplicate record refused on '${object}': ` +
(field
? `a unique constraint on '${field}' already holds this value. `
: 'a unique constraint already holds these values. ') +
(fields?.length === 1
? `a unique constraint on '${fields[0]}' already holds this value. `
: fields && fields.length > 1
? `a unique constraint on ${fields.map((f) => `'${f}'`).join(', ')} already holds these values. `
: 'a unique constraint already holds these values. ') +
'No record was written.'
);
}

/**
* The end user's sentence, rendered from the operation catalog in the writer's
* locale: the one field's label, the composite index's labels as a list
* spelled by the locale's `field_label_quoted` / `field_label_list_separator`
* rules, or nothing named. A composite is named only when EVERY field has a
* label — never an API name in this sentence.
*/
function buildEndUserMessage(
fieldLabel: string | undefined,
uniqueFields: DuplicateRecordLabels['uniqueFields'],
messages: DuplicateRecordLabels['messages'],
): string {
const opts = { locale: messages?.locale, translate: messages?.translate };
if (fieldLabel) {
return renderOperationMessage({ messageKey: 'duplicate_record_field', params: { field: fieldLabel } }, opts);
}
const labels = uniqueFields?.map((f) => f.label);
if (labels && labels.length > 1 && labels.every((l): l is string => typeof l === 'string' && l.length > 0)) {
const separator = renderOperationMessage({ messageKey: 'field_label_list_separator' }, opts);
const list = labels
.map((label) => renderOperationMessage({ messageKey: 'field_label_quoted', params: { label } }, opts))
.join(separator);
return renderOperationMessage({ messageKey: 'duplicate_record_fields', params: { fields: list } }, opts);
}
return renderOperationMessage({ messageKey: 'duplicate_record' }, opts);
}

/**
* A write door's driver-error exit — `insert` (#14095) and `update` (commit 9d7f7259f),
* by-id and predicate alike: the platform envelope for a unique violation, or
Expand Down Expand Up @@ -210,7 +296,61 @@ export function envelopeUniqueViolation(
if (error instanceof DuplicateRecordError) return error;
if (!isUniqueViolationError(error)) return error;
const field = uniqueViolationField(error);
return new DuplicateRecordError(object, error, field, displayLabels(schema, object, field, messages));
const uniqueFields = field ? undefined : declaredCompositeIndex(schema, uniqueViolationColumns(error));
return new DuplicateRecordError(object, error, field, {
...displayLabels(schema, object, field, messages),
...(uniqueFields ? { uniqueFields: uniqueFields.map((name) => ({ name, label: fieldDisplayLabel(schema, object, name, messages) })) } : {}),
...(messages ? { messages: { locale: messages.locale, translate: messages.translate } } : {}),
});
}

/**
* The `fields` of the unique index the object DECLARES whose field set equals
* the composite key the store named — in the index's declared order — or
* `undefined` when none matches exactly. The store's side arrives with the
* organization key part already set aside (`uniqueViolationColumns`), which
* is what a `unique: 'organization'` index lists: only its authored fields.
* The declared side is compared as written, so a legacy hand-written global
* index that lists the organization column itself matches nothing and names
* nothing — the conservative answer. A non-unique index never matches: it
* refuses nothing.
*/
function declaredCompositeIndex(schema: unknown, columns: readonly string[] | undefined): readonly string[] | undefined {
if (!columns || columns.length < 2) return undefined;
const indexes = (schema as { indexes?: unknown } | undefined)?.indexes;
if (!Array.isArray(indexes)) return undefined;
const wanted = new Set(columns);
if (wanted.size !== columns.length) return undefined;
for (const raw of indexes) {
const index = raw as { fields?: unknown; unique?: unknown } | null;
if (!index || typeof index !== 'object') continue;
if (index.unique !== 'global' && index.unique !== 'organization') continue;
if (!Array.isArray(index.fields) || !index.fields.every((f): f is string => typeof f === 'string')) continue;
const declared = index.fields;
if (declared.length === wanted.size && new Set(declared).size === declared.length && declared.every((f) => wanted.has(f))) {
return declared;
}
}
return undefined;
}

/**
* One field's display label in the writer's locale — a translation, else its
* declared `label` — or `undefined` when it has neither (the API name is never
* returned as a label). The ladder {@link displayLabels} climbs for `field`.
*/
function fieldDisplayLabel(
schema: unknown,
object: string,
field: string,
messages: ValidationMessageContext | undefined,
): string | undefined {
const s = schema as { fields?: Record<string, { label?: unknown } | undefined> } | undefined;
const declared = typeof s?.fields?.[field]?.label === 'string' && (s.fields[field]!.label as string).trim() !== ''
? (s.fields[field]!.label as string).trim()
: undefined;
const label = resolveFieldLabel(field, { label: declared }, messages && { ...messages, objectName: object });
return label === field && declared === undefined ? undefined : label;
}

/**
Expand Down
Loading
Loading