Skip to content

Commit 507b92a

Browse files
baozhoutaoclaude
andauthored
fix(spec,objectql,rest,runtime): localize field-validation messages, name the field by its label (#3957) (#4014)
* fix(spec,objectql,rest,runtime): localize field-validation messages, name the field by its label (#3957) The write path built every built-in validation message by concatenating the API field name into a hardcoded English template, and those strings are what the Console toast, the CSV-import row report and every custom client display verbatim. A zh-CN user importing a bad row read `第 1 行:penalty_amount must be ≥ 0` for a field declared `label: '处罚金额'` with a full zh-CN bundle loaded — while the form layer localized the SAME constraint correctly via the browser's native `min`, so the language flipped with whichever layer caught it. - `@objectstack/spec/system` ships the message catalog (en / zh-CN / ja-JP / es-ES) plus `renderValidationMessage`; `@objectstack/spec/data` owns the per-field envelope (`FieldValidationErrorSchema`) that objectql used to hand-declare. Message keys are finer-grained than wire codes, so one `code` can carry several sentences without splitting the client-facing vocabulary. - The locale is `ExecutionContext.locale` — whose contract already read "Drives message catalogs" with no consumer. Both HTTP entries now resolve it from the request's `Accept-Language` / `?locale` first, falling back to the workspace `localization.locale`, so a message and the labels around it cannot come from different locales. - The field is named by its translated label → declared label → API name; `field` still carries the API name for input focus. - `params` exposes the constraint as data (`{ min: 0 }`, `{ maxLength: 512, actual: 3000 }`) so a client can format its own text. - Applied at every call site, not just the validator: single/batch insert, single-id/multi-row update, the rule evaluator's own built-in messages, and the importer's cell-coercion + required pre-check. An author-written `rule.message` is never overridden. Verified end-to-end in the running showcase: the import wizard's row report and the grid's inline-edit save now read 「预算必须大于或等于 0」/「保存失败: 年收入 必须大于或等于 0」 with translated labels, and `Accept-Language` switches the sentence. Pinned by a dogfood test through the real HTTP stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): regenerate the data reference for the new validation-error schema (#3957) AUTO-GEN under content/docs/references — `check:docs` gates it against the spec, and adding `data/validation-error.zod.ts` adds a page plus its two index entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): record the new validation-error / message-catalog exports in the API surface snapshot (#3957) `check:api-surface` ratchets @objectstack/spec's public exports. 17 added, 0 breaking — the per-field error contract plus the message catalog and the shared Accept-Language / field-label key helpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changeset): describe #3957 against ADR-0114's constraint position The changeset predated the ADR-0114 merge and still described a `params` bag and a `FieldValidationErrorSchema` that no longer exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changeset): the migration note points at `constraint`, not the retired `params` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 974c6d4 commit 507b92a

24 files changed

Lines changed: 1870 additions & 85 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/runtime": patch
6+
---
7+
8+
fix(spec,objectql,rest,runtime): field-validation messages answer in the caller's language, named by the field's label (#3957)
9+
10+
The write path built every built-in validation message by concatenating the **API
11+
field name** into a **hardcoded English** template. Those strings are what the
12+
Console toast, the CSV-import row report, the CLI and any custom client display
13+
verbatim, so a Chinese-locale user importing a bad row read:
14+
15+
```
16+
第 1 行:penalty_amount must be ≥ 0
17+
```
18+
19+
…for a field declared `label: '处罚金额'` with a full `zh-CN` bundle loaded. The
20+
form layer localized the *same* constraint correctly (the browser's native
21+
`min`), so the language flipped depending on which layer caught the value.
22+
23+
**Three things changed.**
24+
25+
1. **The message is rendered in the caller's locale** from a built-in catalog
26+
(`BUILTIN_VALIDATION_MESSAGES`, `@objectstack/spec/system`) shipping `en`,
27+
`zh-CN`, `ja-JP`, `es-ES` — the same four locales as the platform bundles.
28+
The locale comes from `ExecutionContext.locale`, whose contract already read
29+
"Drives message catalogs"; this is the consumer that makes that true. Both
30+
HTTP entries (REST server, runtime dispatcher) now resolve it from the
31+
request's `Accept-Language` / `?locale` first, falling back to the workspace
32+
`localization.locale` — so a rejection message and the field labels around it
33+
can no longer disagree.
34+
35+
2. **The field is named by its label, never the API name**: translation bundle
36+
(`objects.<obj>.fields.<f>.label`) → declared `label` → API name as the last
37+
resort. `FieldValidationError.field` still carries the API name so a form can
38+
focus the right input.
39+
40+
3. **The constraint is exposed as data**, so a client can format its own text
41+
instead of parsing the sentence:
42+
`{ field, code, message, label, constraint: { min: 0 } }`. This rides
43+
ADR-0114's existing `constraint` / `value` positions on `FieldErrorSchema`
44+
(`constraint` tightens from `unknown` to `Record<string, unknown>`) rather
45+
than adding a parallel payload — `label` is the only new field. The bag
46+
carries `min`/`max`/`minLength`/`maxLength`/`actual`/`allowed`/`type`, and the
47+
message templates interpolate from exactly those keys.
48+
49+
Covered end-to-end, not only in the validator: single and batch insert,
50+
single-id and multi-row update, ADR-0113's clear-out rejection, the object-level
51+
rule evaluator's own built-in messages (`requiredWhen`, per-option gating,
52+
state-machine fallbacks), and the importer's cell-coercion, required pre-check
53+
and #3956 bound pre-check messages — all of which land in the same row report.
54+
55+
**What this changes for consumers.**
56+
57+
- `code` is unchanged (ADR-0114's `FieldErrorCode`) and remains the thing to
58+
match on. Message keys are finer-grained than codes — `invalid_datetime`,
59+
`invalid_option_value`, `required_cleared` are rendering detail and never reach
60+
the wire — so localization never splits the client-facing vocabulary.
61+
- `message` **text changes**: it is localized, and it names the field by label
62+
even in English (`Budget must be ≥ 0`, not `budget must be ≥ 0`). Anything
63+
asserting on the old English string should match `code` (and now
64+
`constraint`) instead.
65+
- An author-written validation-rule `message` is never touched — it is already
66+
in the language its author chose.
67+
- A deployment can override any built-in message with a `translation` item
68+
defining `validation.field.<messageKey>` (e.g.
69+
`validation.field.min_value: '{{label}}不得小于 {{min}} 元'`).
70+
- The importer's reference-failure message no longer names the target object's
71+
API name (`no sys_user matches "…"`): naming internal identifiers is the
72+
defect being fixed, and the column plus the offending value are what an
73+
importer can act on.

content/docs/references/api/errors.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const result = EnhancedApiError.parse(data);
5353
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
5454
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
5555
| **details** | `any` | optional | Additional error context |
56-
| **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 |
56+
| **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 |
5757
| **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. |
5858
| **timestamp** | `string` | optional | When the error occurred |
5959
| **requestId** | `string` | optional | Request ID for tracking |
@@ -102,9 +102,10 @@ const result = EnhancedApiError.parse(data);
102102
| :--- | :--- | :--- | :--- |
103103
| **field** | `string` || Field path (supports dot notation) |
104104
| **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) |
105-
| **message** | `string` || Human-readable error message |
105+
| **message** | `string` || Human-readable error message, rendered in the caller’s locale |
106+
| **label** | `string` | optional | Field display label in the caller’s locale |
106107
| **value** | `any` | optional | The invalid value that was provided |
107-
| **constraint** | `any` | optional | The constraint that was violated (e.g., max length) |
108+
| **constraint** | `Record<string, any>` | optional | The constraint that was violated, as discrete values (e.g. `{ maxLength: 512, actual: 3000 }`) |
108109

109110

110111
---
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { ObjectQL } from './engine';
5+
import { SchemaRegistry } from './registry';
6+
7+
/**
8+
* #3957 — the CALL SITE, not the catalog.
9+
*
10+
* `record-validator.ts` can localize perfectly and still ship English if the
11+
* engine never hands it a locale. `ExecutionContext.locale` has carried the
12+
* contract "Drives message catalogs and number/date formatting" since ADR-0053
13+
* Phase 2 with no consumer — declared ≠ enforced (AGENTS.md PD #10). These
14+
* tests assert on what a caller actually gets back from `ql.insert` /
15+
* `ql.update`, for every write shape the engine validates: single insert, batch
16+
* insert, single-id update, and multi-row update.
17+
*/
18+
vi.mock('./registry', () => {
19+
const instance: any = {
20+
getObject: vi.fn(),
21+
resolveObject: vi.fn((n: string) => instance.getObject(n)),
22+
registerObject: vi.fn(),
23+
getObjectOwner: vi.fn(),
24+
registerNamespace: vi.fn(),
25+
registerKind: vi.fn(),
26+
registerItem: vi.fn(),
27+
registerApp: vi.fn(),
28+
installPackage: vi.fn(),
29+
reset: vi.fn(),
30+
metadata: { get: vi.fn(() => new Map()) },
31+
};
32+
function SchemaRegistry() {
33+
return instance;
34+
}
35+
Object.assign(SchemaRegistry, instance);
36+
return {
37+
SchemaRegistry,
38+
computeFQN: (_ns: string | undefined, name: string) => name,
39+
parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }),
40+
RESERVED_NAMESPACES: new Set(['base', 'system']),
41+
};
42+
});
43+
44+
// The reporting object from the issue: Chinese labels, range-guarded currency.
45+
const SETTLEMENT_SCHEMA = {
46+
name: 'mes_settlement',
47+
fields: {
48+
name: { type: 'text', label: '名称' },
49+
penalty_amount: { type: 'currency', label: '处罚金额', min: 0 },
50+
quota_hours: { type: 'number', label: '定额工时', min: 0 },
51+
},
52+
};
53+
54+
function makeDriver() {
55+
const driver: any = {
56+
name: 'memory',
57+
supports: {},
58+
connect: vi.fn().mockResolvedValue(undefined),
59+
disconnect: vi.fn().mockResolvedValue(undefined),
60+
find: vi.fn().mockResolvedValue([{ id: 'r1' }, { id: 'r2' }]),
61+
findOne: vi.fn().mockResolvedValue({ id: 'r1' }),
62+
create: vi.fn(async (_o: string, row: any) => ({ id: 'r1', ...row })),
63+
update: vi.fn(async (_o: string, id: string, row: any) => ({ id, ...row })),
64+
updateMany: vi.fn(async () => 2),
65+
delete: vi.fn(),
66+
};
67+
return driver;
68+
}
69+
70+
async function makeEngine(driver: any) {
71+
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
72+
name === 'mes_settlement' ? SETTLEMENT_SCHEMA : undefined,
73+
);
74+
const ql = new ObjectQL();
75+
ql.registerDriver(driver, true);
76+
await ql.init();
77+
return ql;
78+
}
79+
80+
/** The message a write attempt fails with. */
81+
async function messageOf(run: () => Promise<unknown>): Promise<string> {
82+
try {
83+
await run();
84+
} catch (e: any) {
85+
return String(e?.message ?? '');
86+
}
87+
throw new Error('expected the write to be rejected');
88+
}
89+
90+
const zhCN = { locale: 'zh-CN' } as any;
91+
92+
describe('engine write path — validation messages honour ExecutionContext.locale (#3957)', () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
});
96+
97+
it('single insert: a zh-CN principal reads Chinese', async () => {
98+
const ql = await makeEngine(makeDriver());
99+
const msg = await messageOf(() =>
100+
ql.insert('mes_settlement', { name: 'S1', penalty_amount: -1 }, { context: zhCN }),
101+
);
102+
expect(msg).toBe('处罚金额必须大于或等于 0');
103+
expect(msg).not.toContain('penalty_amount');
104+
});
105+
106+
it('batch insert: each rejected row is localized', async () => {
107+
const ql = await makeEngine(makeDriver());
108+
const msg = await messageOf(() =>
109+
ql.insert(
110+
'mes_settlement',
111+
[{ name: 'ok', penalty_amount: 1 }, { name: 'bad', penalty_amount: -1 }],
112+
{ context: zhCN },
113+
),
114+
);
115+
expect(msg).toBe('处罚金额必须大于或等于 0');
116+
});
117+
118+
it('single-id update: localized', async () => {
119+
const ql = await makeEngine(makeDriver());
120+
const msg = await messageOf(() =>
121+
ql.update('mes_settlement', { id: 'r1', quota_hours: -3 }, { context: zhCN }),
122+
);
123+
expect(msg).toBe('定额工时必须大于或等于 0');
124+
});
125+
126+
it('multi-row update: localized (the bulk call site, cf. #3106)', async () => {
127+
const driver = makeDriver();
128+
const ql = await makeEngine(driver);
129+
const msg = await messageOf(() =>
130+
ql.update(
131+
'mes_settlement',
132+
{ quota_hours: -3 },
133+
{ multi: true, filters: [['name', '=', 'S1']], context: zhCN } as any,
134+
),
135+
);
136+
expect(msg).toBe('定额工时必须大于或等于 0');
137+
// The write never reached storage.
138+
expect(driver.updateMany).not.toHaveBeenCalled();
139+
});
140+
141+
/**
142+
* No principal locale (a system write, a programmatic call, an anonymous
143+
* request) keeps the pre-#3957 English rendering — now against the declared
144+
* label instead of the API name.
145+
*/
146+
it('no locale: English, and still the label rather than the column name', async () => {
147+
const ql = await makeEngine(makeDriver());
148+
const msg = await messageOf(() =>
149+
ql.insert('mes_settlement', { name: 'S1', penalty_amount: -1 }),
150+
);
151+
expect(msg).toBe('处罚金额 must be ≥ 0');
152+
});
153+
154+
/**
155+
* An app whose metadata is authored in English but shipped with a `zh-CN`
156+
* bundle: the bridged i18n service supplies the translated label.
157+
*/
158+
it('uses the bridged i18n service for the field label and message overrides', async () => {
159+
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
160+
name === 'mes_settlement'
161+
? {
162+
name: 'mes_settlement',
163+
fields: { penalty_amount: { type: 'currency', label: 'Penalty Amount', min: 0 } },
164+
}
165+
: undefined,
166+
);
167+
const ql = new ObjectQL();
168+
ql.registerDriver(makeDriver(), true);
169+
await ql.init();
170+
ql.setI18nService({
171+
t: (key: string, locale: string) => {
172+
if (key === 'objects.mes_settlement.fields.penalty_amount.label' && locale === 'zh-CN') return '处罚金额';
173+
if (key === 'validation.field.min_value' && locale === 'zh-CN') return '{{label}}不得小于 {{min}} 元';
174+
return key;
175+
},
176+
});
177+
const msg = await messageOf(() =>
178+
ql.insert('mes_settlement', { penalty_amount: -1 }, { context: zhCN }),
179+
);
180+
expect(msg).toBe('处罚金额不得小于 0 元');
181+
});
182+
183+
it('a client can format its own text from code + constraint', async () => {
184+
const ql = await makeEngine(makeDriver());
185+
try {
186+
await ql.insert('mes_settlement', { penalty_amount: -1 }, { context: zhCN });
187+
throw new Error('expected the write to be rejected');
188+
} catch (e: any) {
189+
expect(e.code).toBe('VALIDATION_FAILED');
190+
expect(e.fields).toEqual([
191+
expect.objectContaining({
192+
field: 'penalty_amount',
193+
code: 'min_value',
194+
label: '处罚金额',
195+
constraint: { min: 0 },
196+
}),
197+
]);
198+
}
199+
});
200+
});

0 commit comments

Comments
 (0)