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
73 changes: 73 additions & 0 deletions .changeset/localized-field-validation-messages.md
Original file line number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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