Skip to content
Merged
73 changes: 73 additions & 0 deletions .changeset/localized-field-validation-messages.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/rest": minor
"@objectstack/runtime": patch
---

fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957)

The write path built every built-in validation message by concatenating the **API
field name** into a **hardcoded English** template. Those strings are what the
Console toast, the CSV-import row report, the CLI and any custom client display
verbatim, so a Chinese-locale user importing a bad row read:

```
第 1 行:penalty_amount must be ≥ 0
```

…for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The
form layer localized the *same* constraint correctly (the browser's native
`min`), so the language flipped depending on which layer caught the value.

**Three things changed.**

1. **The message is rendered in the caller's locale** from a built-in catalog
(`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`,
`zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles.
The locale comes from `ExecutionContext.locale`, whose contract already read
"Drives message catalogs"; this is the consumer that makes that true. Both
HTTP entries (REST server, runtime dispatcher) now resolve it from the
request's `Accept-Language` / `?locale` first, falling back to the workspace
`localization.locale` — so a rejection message and the field labels around it
can no longer disagree.

2. **The field is named by its label, never the API name**: translation bundle
(`objects.<obj>.fields.<f>.label`) → declared `label` → API name as the last
resort. `FieldValidationError.field` still carries the API name so a form can
focus the right input.

3. **The constraint is exposed as data**, so a client can format its own text
instead of parsing the sentence:
`{ field, code, message, label, constraint: { min: 0 } }`. This rides
ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema`
(`constraint` tightens from `unknown` to `Record<string, unknown>`) rather
than adding a parallel payload — `label` is the only new field. The bag
carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the
message templates interpolate from exactly those keys.

Covered end-to-end, not only in the validator: single and batch insert,
single-id and multi-row update, ADR-0113's clear-out rejection, the object-level
rule evaluator's own built-in messages (`requiredWhen`, per-option gating,
state-machine fallbacks), and the importer's cell-coercion, required pre-check
and #3956 bound pre-check messages — all of which land in the same row report.

**What this changes for consumers.**

- `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to
match on. Message keys are finer-grained than codes — `invalid_datetime`,
`invalid_option_value`, `required_cleared` are rendering detail and never reach
the wire — so localization never splits the client-facing vocabulary.
- `message` **text changes**: it is localized, and it names the field by label
even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything
asserting on the old English string should match `code` (and now
`constraint`) instead.
- An author-written validation-rule `message` is never touched — it is already
in the language its author chose.
- A deployment can override any built-in message with a `translation` item
defining `validation.field.<messageKey>` (e.g.
`validation.field.min_value: '{{label}}不得小于 {{min}} 元'`).
- The importer's reference-failure message no longer names the target object's
API name (`no sys_user matches "…"`): naming internal identifiers is the
defect being fixed, and the column plus the offending value are what an
importer can act on.
7 changes: 4 additions & 3 deletions content/docs/references/api/errors.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,7 +53,7 @@ const result = EnhancedApiError.parse(data);
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
| **details** | `any` | optional | Additional error context |
| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>; message: string; value?: any; … }[]` | optional | One entry per offending value |
| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>; message: string; label?: string; … }[]` | optional | One entry per offending value |
| **fieldErrors** | `any` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. |
| **timestamp** | `string` | optional | When the error occurred |
| **requestId** | `string` | optional | Request ID for tracking |
Expand DownExpand Up@@ -102,9 +102,10 @@ const result = EnhancedApiError.parse(data);
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field path (supports dot notation) |
| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) |
| **message** | `string` | ✅ | Human-readable error message |
| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale |
| **label** | `string` | optional | Field display label in the caller’s locale |
| **value** | `any` | optional | The invalid value that was provided |
| **constraint** | `any` | optional | The constraint that was violated(e.g., max length) |
| **constraint** | `Record<string, any>` | optional | The constraint that was violated, as discrete values (e.g. `{ maxLength: 512, actual: 3000 }`) |


---
Expand Down
200 changes: 200 additions & 0 deletions packages/objectql/src/engine-validation-locale.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ObjectQL } from './engine';
import { SchemaRegistry } from './registry';

/**
* #3957 — the CALL SITE, not the catalog.
*
* `record-validator.ts` can localize perfectly and still ship English if the
* engine never hands it a locale. `ExecutionContext.locale` has carried the
* contract "Drives message catalogs and number/date formatting" since ADR-0053
* Phase 2 with no consumer — declared ≠ enforced (AGENTS.md PD #10). These
* tests assert on what a caller actually gets back from `ql.insert` /
* `ql.update`, for every write shape the engine validates: single insert, batch
* insert, single-id update, and multi-row update.
*/
vi.mock('./registry', () => {
const instance: any = {
getObject: vi.fn(),
resolveObject: vi.fn((n: string) => instance.getObject(n)),
registerObject: vi.fn(),
getObjectOwner: vi.fn(),
registerNamespace: vi.fn(),
registerKind: vi.fn(),
registerItem: vi.fn(),
registerApp: vi.fn(),
installPackage: vi.fn(),
reset: vi.fn(),
metadata: { get: vi.fn(() => new Map()) },
};
function SchemaRegistry() {
return instance;
}
Object.assign(SchemaRegistry, instance);
return {
SchemaRegistry,
computeFQN: (_ns: string | undefined, name: string) => name,
parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }),
RESERVED_NAMESPACES: new Set(['base', 'system']),
};
});

// The reporting object from the issue: Chinese labels, range-guarded currency.
const SETTLEMENT_SCHEMA = {
name: 'mes_settlement',
fields: {
name: { type: 'text', label: '名称' },
penalty_amount: { type: 'currency', label: '处罚金额', min: 0 },
quota_hours: { type: 'number', label: '定额工时', min: 0 },
},
};

function makeDriver() {
const driver: any = {
name: 'memory',
supports: {},
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
find: vi.fn().mockResolvedValue([{ id: 'r1' }, { id: 'r2' }]),
findOne: vi.fn().mockResolvedValue({ id: 'r1' }),
create: vi.fn(async (_o: string, row: any) => ({ id: 'r1', ...row })),
update: vi.fn(async (_o: string, id: string, row: any) => ({ id, ...row })),
updateMany: vi.fn(async () => 2),
delete: vi.fn(),
};
return driver;
}

async function makeEngine(driver: any) {
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
name === 'mes_settlement' ? SETTLEMENT_SCHEMA : undefined,
);
const ql = new ObjectQL();
ql.registerDriver(driver, true);
await ql.init();
return ql;
}

/** The message a write attempt fails with. */
async function messageOf(run: () => Promise<unknown>): Promise<string> {
try {
await run();
} catch (e: any) {
return String(e?.message ?? '');
}
throw new Error('expected the write to be rejected');
}

const zhCN = { locale: 'zh-CN' } as any;

describe('engine write path — validation messages honour ExecutionContext.locale (#3957)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('single insert: a zh-CN principal reads Chinese', async () => {
const ql = await makeEngine(makeDriver());
const msg = await messageOf(() =>
ql.insert('mes_settlement', { name: 'S1', penalty_amount: -1 }, { context: zhCN }),
);
expect(msg).toBe('处罚金额必须大于或等于 0');
expect(msg).not.toContain('penalty_amount');
});

it('batch insert: each rejected row is localized', async () => {
const ql = await makeEngine(makeDriver());
const msg = await messageOf(() =>
ql.insert(
'mes_settlement',
[{ name: 'ok', penalty_amount: 1 }, { name: 'bad', penalty_amount: -1 }],
{ context: zhCN },
),
);
expect(msg).toBe('处罚金额必须大于或等于 0');
});

it('single-id update: localized', async () => {
const ql = await makeEngine(makeDriver());
const msg = await messageOf(() =>
ql.update('mes_settlement', { id: 'r1', quota_hours: -3 }, { context: zhCN }),
);
expect(msg).toBe('定额工时必须大于或等于 0');
});

it('multi-row update: localized (the bulk call site, cf. #3106)', async () => {
const driver = makeDriver();
const ql = await makeEngine(driver);
const msg = await messageOf(() =>
ql.update(
'mes_settlement',
{ quota_hours: -3 },
{ multi: true, filters: [['name', '=', 'S1']], context: zhCN } as any,
),
);
expect(msg).toBe('定额工时必须大于或等于 0');
// The write never reached storage.
expect(driver.updateMany).not.toHaveBeenCalled();
});

/**
* No principal locale (a system write, a programmatic call, an anonymous
* request) keeps the pre-#3957 English rendering — now against the declared
* label instead of the API name.
*/
it('no locale: English, and still the label rather than the column name', async () => {
const ql = await makeEngine(makeDriver());
const msg = await messageOf(() =>
ql.insert('mes_settlement', { name: 'S1', penalty_amount: -1 }),
);
expect(msg).toBe('处罚金额 must be ≥ 0');
});

/**
* An app whose metadata is authored in English but shipped with a `zh-CN`
* bundle: the bridged i18n service supplies the translated label.
*/
it('uses the bridged i18n service for the field label and message overrides', async () => {
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
name === 'mes_settlement'
? {
name: 'mes_settlement',
fields: { penalty_amount: { type: 'currency', label: 'Penalty Amount', min: 0 } },
}
: undefined,
);
const ql = new ObjectQL();
ql.registerDriver(makeDriver(), true);
await ql.init();
ql.setI18nService({
t: (key: string, locale: string) => {
if (key === 'objects.mes_settlement.fields.penalty_amount.label' && locale === 'zh-CN') return '处罚金额';
if (key === 'validation.field.min_value' && locale === 'zh-CN') return '{{label}}不得小于 {{min}} 元';
return key;
},
});
const msg = await messageOf(() =>
ql.insert('mes_settlement', { penalty_amount: -1 }, { context: zhCN }),
);
expect(msg).toBe('处罚金额不得小于 0 元');
});

it('a client can format its own text from code + constraint', async () => {
const ql = await makeEngine(makeDriver());
try {
await ql.insert('mes_settlement', { penalty_amount: -1 }, { context: zhCN });
throw new Error('expected the write to be rejected');
} catch (e: any) {
expect(e.code).toBe('VALIDATION_FAILED');
expect(e.fields).toEqual([
expect.objectContaining({
field: 'penalty_amount',
code: 'min_value',
label: '处罚金额',
constraint: { min: 0 },
}),
]);
}
});
});
Loading
Loading