From ae0c947c3432a356cc5cb1627d6156b80228ac35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 29 Jul 2026 22:38:23 -0700 Subject: [PATCH 1/5] fix(spec,objectql,rest,runtime): localize field-validation messages, name the field by its label (#3957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../localized-field-validation-messages.md | 68 ++++ .../src/engine-validation-locale.test.ts | 200 ++++++++++++ packages/objectql/src/engine.ts | 63 +++- packages/objectql/src/plugin.ts | 17 + .../src/validation/record-validator.test.ts | 158 +++++++++ .../src/validation/record-validator.ts | 199 +++++++++--- .../src/validation/rule-validator.test.ts | 108 +++++++ .../objectql/src/validation/rule-validator.ts | 75 +++-- .../validation-message-locale.dogfood.test.ts | 181 +++++++++++ packages/rest/src/import-coerce.test.ts | 65 ++++ packages/rest/src/import-coerce.ts | 62 +++- packages/rest/src/import-integration.test.ts | 8 +- packages/rest/src/import-prepare.ts | 25 +- packages/rest/src/import-runner.ts | 27 +- packages/rest/src/rest-server.ts | 44 ++- .../src/security/resolve-execution-context.ts | 10 +- packages/spec/authorable-surface.json | 17 + packages/spec/json-schema.manifest.json | 3 + packages/spec/src/data/index.ts | 3 + .../spec/src/data/validation-error.zod.ts | 135 ++++++++ packages/spec/src/system/i18n-resolver.ts | 40 ++- packages/spec/src/system/index.ts | 2 + .../src/system/validation-message.test.ts | 205 ++++++++++++ .../spec/src/system/validation-message.ts | 303 ++++++++++++++++++ 24 files changed, 1920 insertions(+), 98 deletions(-) create mode 100644 .changeset/localized-field-validation-messages.md create mode 100644 packages/objectql/src/engine-validation-locale.test.ts create mode 100644 packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts create mode 100644 packages/spec/src/data/validation-error.zod.ts create mode 100644 packages/spec/src/system/validation-message.test.ts create mode 100644 packages/spec/src/system/validation-message.ts diff --git a/.changeset/localized-field-validation-messages.md b/.changeset/localized-field-validation-messages.md new file mode 100644 index 0000000000..c449100f8e --- /dev/null +++ b/.changeset/localized-field-validation-messages.md @@ -0,0 +1,68 @@ +--- +"@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..fields..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, params: { min: 0 } }`. `params` carries + `min`/`max`/`minLength`/`maxLength`/`actual`/`value`/`allowed`/`type`. The + per-field envelope is now one Zod source — `FieldValidationErrorSchema` in + `@objectstack/spec/data` — instead of a hand-written interface in `objectql`. + +Covered end-to-end, not only in the validator: single and batch insert, +single-id and multi-row update, 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 messages, which land in the same +row report. + +**What this changes for consumers.** + +- `code` is unchanged and remains the thing to match on. +- `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 `params`) + 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.` (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. diff --git a/packages/objectql/src/engine-validation-locale.test.ts b/packages/objectql/src/engine-validation-locale.test.ts new file mode 100644 index 0000000000..e04b66c84b --- /dev/null +++ b/packages/objectql/src/engine-validation-locale.test.ts @@ -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): Promise { + 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 + params', 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: '处罚金额', + params: { min: 0 }, + }), + ]); + } + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5ec3fbe48b..6f3ad855cc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -398,6 +398,11 @@ export class ObjectQL implements IDataEngine { // Realtime service for event publishing private realtimeService?: IRealtimeService; + // i18n service backing validation-message + field-label localization (#3957). + // Optional: without it, messages render from the built-in catalog against the + // declared labels. + private i18nService?: { t?: (key: string, locale: string, params?: Record) => string }; + // Crypto provider backing `secret`-typed fields. Optional: when absent, // writing an object that declares a secret field fails closed (never // persists cleartext). Injected by the host via setCryptoProvider(). @@ -1580,6 +1585,46 @@ export class ObjectQL implements IDataEngine { this.logger.info('RealtimeService configured for data events'); } + /** + * Set the i18n service used to localize write-path validation messages and + * the field labels inside them (#3957). Bridged by `ObjectQLPlugin` on start, + * the same way the realtime service is. + * + * Optional by design: with no service the built-in message catalog + * (`@objectstack/spec/system`) still renders each message in the caller's + * locale against the field's DECLARED label. The service adds two things — + * a deployment's `validation.field.*` message overrides, and the field's + * TRANSLATED label for apps whose declared labels are in another language. + */ + setI18nService(service: { t?: (key: string, locale: string, params?: Record) => string }): void { + this.i18nService = service; + this.logger.info('I18nService configured for validation messages'); + } + + /** + * Locale + translation hooks handed to the validators so a rejected write is + * reported in the caller's language (#3957). + * + * `ExecutionContext.locale` is resolved once per request from the + * `localization` settings (ADR-0053 Phase 2) and its contract already reads + * "Drives message catalogs" — this is the consumer that makes that true. + * Undefined locale (anonymous / programmatic / system write) leaves the + * built-in `en` rendering in place. + */ + private validationMessageContext( + objectName: string, + // Only `locale` is read — narrower than `ExecutionContext` so both the + // resolved and the input-shaped envelope satisfy it. + context?: { locale?: string }, + ): { locale?: string; translate?: (key: string, locale: string, params?: Record) => string; objectName: string } { + const t = this.i18nService?.t; + return { + objectName, + locale: context?.locale, + translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined, + }; + } + /** * Register the crypto provider that backs `secret`-typed fields. * @@ -2991,12 +3036,15 @@ export class ObjectQL implements IDataEngine { // Resolved once for the whole batch; dormant unless the object declares // a media field, and memoized after the first object that does. const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(schemaForValidation); + // Locale + translation hooks for the rejection messages (#3957) — + // resolved once for the batch, identical for every row. + const msgCtx = this.validationMessageContext(object, opCtx.context); for (let i = 0; i < rows.length; i++) { if (rowErrors[i] !== undefined) continue; try { normalizeMultiValueFields(schemaForValidation, rows[i]); - validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict }); - evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context) }); + validateRecord(schemaForValidation, rows[i], 'insert', { mediaValueShapeStrict, messages: msgCtx }); + evaluateValidationRules(schemaForValidation as any, rows[i], 'insert', { logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: msgCtx }); } catch (e) { if (!partialMode) throw e; rowErrors[i] = e; @@ -3277,10 +3325,11 @@ export class ObjectQL implements IDataEngine { let priorRecord: Record | null = null; const updateSchema = this._registry.getObject(object); const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(updateSchema); + const updateMsgCtx = this.validationMessageContext(object, opCtx.context); if (hookContext.input.id) { await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options); normalizeMultiValueFields(updateSchema, hookContext.input.data as Record); - validateRecord(updateSchema, hookContext.input.data as Record, 'update', { mediaValueShapeStrict }); + validateRecord(updateSchema, hookContext.input.data as Record, 'update', { mediaValueShapeStrict, messages: updateMsgCtx }); if (needsPriorRecord(updateSchema as any) || (this.hooks.get('afterUpdate')?.length ?? 0) > 0) { const priorAst: QueryAST = { object, where: { id: hookContext.input.id }, limit: 1 }; priorRecord = await driver.findOne(object, priorAst, hookContext.input.options as any); @@ -3301,12 +3350,12 @@ export class ObjectQL implements IDataEngine { hookContext.input.data = stripReadonlyFields(updateSchema as any, preRo, suppliedKeys, this.logger, { preserveAudit: opCtx.context?.preserveAudit === true }) as any; reportDroppedFields(preRo, hookContext.input.data as Record, 'readonly'); } - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context), skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); } else if (options?.multi && driver.updateMany) { await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options); normalizeMultiValueFields(updateSchema, hookContext.input.data as Record); - validateRecord(updateSchema, hookContext.input.data as Record, 'update', { mediaValueShapeStrict }); + validateRecord(updateSchema, hookContext.input.data as Record, 'update', { mediaValueShapeStrict, messages: updateMsgCtx }); // [#2982] Consume the middleware-composed AST seeded above, so // the injected row-scoping (RLS write filter, sharing's // editable-rows filter) actually binds the driver operation. Fail @@ -3369,7 +3418,7 @@ export class ObjectQL implements IDataEngine { if (rulesNeedRows) { for (const row of priorRows ?? []) { try { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: row, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); } catch (err) { if (err instanceof ValidationError && row?.id != null) { throw new ValidationError(err.fields.map((f) => ({ ...f, message: `${f.message} (record ${String(row.id)})` }))); @@ -3378,7 +3427,7 @@ export class ObjectQL implements IDataEngine { } } } else { - evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context) }); + evaluateValidationRules(updateSchema as any, hookContext.input.data as Record, 'update', { previous: null, logger: this.logger, currentUser: bulkEvalUser, skipStateMachine: shouldSkipStateMachine(opCtx.context), messages: updateMsgCtx }); } result = await driver.updateMany(object, ast, hookContext.input.data as Record, hookContext.input.options as any); } else { diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 04c2169cac..70f441bc5f 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -412,6 +412,23 @@ export class ObjectQLPlugin implements Plugin { error: e.message, }); } + + // Bridge the i18n service so a rejected write reports in the caller's + // language (#3957): it supplies both the `validation.field.*` message + // overrides and the field's TRANSLATED label. Absent service is fine — + // the built-in catalog in `@objectstack/spec/system` still localizes the + // message against the field's declared label. + try { + const i18nService = ctx.getService('i18n'); + if (i18nService && typeof (i18nService as any).t === 'function') { + ctx.logger.info('[ObjectQLPlugin] Bridging i18n service to ObjectQL for validation messages'); + this.ql.setI18nService(i18nService as any); + } + } catch (e: any) { + ctx.logger.debug('[ObjectQLPlugin] No i18n service found — validation messages use the built-in catalog', { + error: e.message, + }); + } } // Initialize drivers (calls driver.connect() which sets up persistence) diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index be06898db7..6fe79fd903 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -507,3 +507,161 @@ describe('validateRecord — media value shapes gate on the deployment flag (#36 expect(() => validateRecord(schema, { ...legacyMedia }, 'insert')).not.toThrow(); }); }); + +/** + * #3957 — a rejected write must name the field the way the USER knows it, in + * the language they read, and must hand a client the constraint as data. + * + * Before this, `validateOne` string-concatenated the API field name into a + * hardcoded English template: a `zh-CN` user importing a bad row read + * `penalty_amount must be ≥ 0` for a field declared `label: '处罚金额'`. The + * form layer localized the SAME constraint correctly (native `min`), so the + * language flipped depending on which layer caught the value. + */ +describe('validateRecord — messages name the field by its label (#3957)', () => { + const schema = { + fields: { + penalty_amount: { type: 'currency', label: '处罚金额', min: 0 }, + remark: { type: 'text', label: '备注', maxLength: 512 }, + }, + }; + + const fieldsOf = (data: Record, mode: 'insert' | 'update' = 'update', options = {}) => { + try { + validateRecord(schema, data, mode, options); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + it('uses the declared label, not the API name, even with no locale', () => { + const [err] = fieldsOf({ penalty_amount: -5 }); + expect(err.message).toBe('处罚金额 must be ≥ 0'); + expect(err.message).not.toContain('penalty_amount'); + // The API name stays available for the UI to focus the right input. + expect(err.field).toBe('penalty_amount'); + expect(err.label).toBe('处罚金额'); + }); + + it('renders the whole sentence in the caller’s locale', () => { + const [err] = fieldsOf({ penalty_amount: -5 }, 'update', { + messages: { locale: 'zh-CN', objectName: 'mes_settlement' }, + }); + expect(err.message).toBe('处罚金额必须大于或等于 0'); + }); + + it('falls back to the API name only when no label is declared', () => { + const bare = { fields: { qty: { type: 'number', min: 1 } } }; + try { + validateRecord(bare, { qty: 0 }, 'update'); + } catch (e) { + const [err] = (e as ValidationError).fields; + expect(err.message).toBe('qty must be ≥ 1'); + expect(err.label).toBe('qty'); + } + }); + + /** + * Option 2 of the issue: the bound as a discrete value, so a custom client + * formats its own text instead of parsing the sentence. + */ + it('carries the constraint as structured params', () => { + expect(fieldsOf({ penalty_amount: -5 })[0]).toMatchObject({ + code: 'min_value', + params: { min: 0 }, + }); + expect(fieldsOf({ remark: 'x'.repeat(3000) })[0]).toMatchObject({ + code: 'max_length', + params: { maxLength: 512, actual: 3000 }, + }); + }); + + it('localizes the whole built-in catalog, not just the range codes', () => { + const zoo = { + fields: { + title: { type: 'text', label: '标题', required: true }, + email: { type: 'email', label: '邮箱' }, + site: { type: 'url', label: '网址' }, + tel: { type: 'phone', label: '电话' }, + qty: { type: 'number', label: '数量' }, + flag: { type: 'boolean', label: '标记' }, + due: { type: 'date', label: '截止日' }, + at: { type: 'datetime', label: '发生时间' }, + clock: { type: 'time', label: '打卡时间' }, + stage: { type: 'select', label: '阶段', options: ['a', 'b'] }, + tags: { type: 'multiselect', label: '标签', options: ['a', 'b'] }, + }, + }; + const msgs = { locale: 'zh-CN', objectName: 'zoo' }; + const one = (data: Record, mode: 'insert' | 'update' = 'update') => { + try { + validateRecord(zoo, data, mode, { messages: msgs }); + } catch (e) { + return (e as ValidationError).fields[0]; + } + throw new Error('expected a ValidationError'); + }; + + expect(one({}, 'insert').message).toBe('标题不能为空'); + expect(one({ email: 'nope' }).message).toBe('邮箱必须是有效的电子邮件地址'); + expect(one({ site: 'notaurl' }).message).toBe('网址必须是有效的 URL(scheme://...)'); + expect(one({ tel: 'x' }).message).toBe('电话必须是有效的电话号码'); + expect(one({ qty: 'abc' }).message).toBe('数量必须是数字'); + expect(one({ flag: 'maybe' }).message).toBe('标记必须是 true 或 false'); + expect(one({ due: 'not-a-date' }).message).toBe('截止日必须是有效的日期(ISO-8601)'); + // Same wire code as `date`, different sentence. + expect(one({ at: 'not-a-date' }).message).toBe('发生时间必须是有效的日期时间(ISO-8601)'); + expect(one({ at: 'not-a-date' }).code).toBe('invalid_date'); + expect(one({ clock: '99:99' }).message).toBe('打卡时间必须是有效的时间(HH:MM 或 HH:MM:SS)'); + expect(one({ stage: 'z' }).message).toBe('阶段必须是以下值之一:a, b'); + expect(one({ tags: ['a', 'z'] }).message).toBe('标签:“z”不在允许的取值范围内:a, b'); + expect(one({ tags: 'a,b' as unknown as string[] }).message).toBe('标签必须是数组'); + }); + + /** + * The declared label is only the SOURCE language. An app whose metadata is + * authored in English but shipped with a `zh-CN` bundle must read Chinese — + * the case the issue reported as "has a zh-CN translation. Neither is used". + */ + it('prefers the translated label over the declared one', () => { + const en = { fields: { penalty_amount: { type: 'currency', label: 'Penalty Amount', min: 0 } } }; + const translate = (key: string, locale: string) => + key === 'objects.mes_settlement.fields.penalty_amount.label' && locale === 'zh-CN' + ? '处罚金额' + : key; + try { + validateRecord(en, { penalty_amount: -5 }, 'update', { + messages: { locale: 'zh-CN', objectName: 'mes_settlement', translate }, + }); + } catch (e) { + const [err] = (e as ValidationError).fields; + expect(err.message).toBe('处罚金额必须大于或等于 0'); + expect(err.label).toBe('处罚金额'); + } + }); + + it('survives a throwing i18n service instead of 500-ing the write', () => { + const translate = () => { throw new Error('i18n exploded'); }; + const [err] = fieldsOf({ penalty_amount: -5 }, 'update', { + messages: { locale: 'zh-CN', objectName: 'mes_settlement', translate }, + }); + expect(err.message).toBe('处罚金额必须大于或等于 0'); + }); + + /** + * The top-level `ValidationError.message` is what a toast shows. It joins the + * per-field messages, so localizing them localizes it — no separate path. + */ + it('the top-level message is localized too', () => { + try { + validateRecord(schema, { penalty_amount: -5, remark: 'x'.repeat(3000) }, 'update', { + messages: { locale: 'zh-CN', objectName: 'mes_settlement' }, + }); + } catch (e) { + expect((e as ValidationError).message).toBe( + '处罚金额必须大于或等于 0; 备注长度不能超过 512 个字符(当前 3000 个)', + ); + } + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index f63ae2f81d..46293288d8 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -37,7 +37,15 @@ import { REFERENCE_VALUE_TYPES, FILE_REFERENCE_TYPES, STRUCTURED_JSON_TYPES, + type FieldValidationCode, + type FieldValidationError, + type FieldValidationParams, } from '@objectstack/spec/data'; +import { + renderValidationMessage, + objectFieldLabelKey, + type ValidationMessageTranslator, +} from '@objectstack/spec/system'; // Lifecycle columns the engine always owns and the client never supplies. These // are skipped by NAME because they are not author-declared business fields. @@ -74,34 +82,11 @@ const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/; const URL_RE = /^(?:[a-z][a-z0-9+.\-]*:\/\/[^\s]+|\/[^\s]*|data:[^\s]+|blob:[^\s]+)$/i; const PHONE_RE = /^[+()\-\s\d.]{5,}$/; -export interface FieldValidationError { - field: string; - code: - | 'required' - | 'min_length' - | 'max_length' - | 'min_value' - | 'max_value' - | 'invalid_email' - | 'invalid_url' - | 'invalid_phone' - | 'invalid_number' - | 'invalid_boolean' - | 'invalid_date' - | 'invalid_time' - | 'invalid_option' - | 'invalid_type' - // Object-level validation rules (ADR-0020, see rule-validator.ts) - | 'invalid_transition' - | 'invalid_initial_state' - | 'rule_violation' - | 'invalid_format' - | 'invalid_json' - | 'json_schema_violation'; - message: string; - /** Allowed values for select/multiselect, when applicable. */ - options?: string[]; -} +// The per-field error envelope is a PROTOCOL, not a local shape: REST ships it +// verbatim and clients match on `code`. It lives in `@objectstack/spec/data` +// (`validation-error.zod.ts`) — Zod-first, one definition — and is re-exported +// here because every existing importer reaches for it through this module. +export type { FieldValidationCode, FieldValidationError, FieldValidationParams }; export class ValidationError extends Error { readonly code = 'VALIDATION_FAILED'; @@ -127,6 +112,12 @@ type Mode = 'insert' | 'update'; interface FieldDef { name?: string; + /** + * Author-declared display label. The message templates name the field by + * THIS, not by `name` — a user has never seen `penalty_amount`, and on a + * localized app the declared label is already in their language (#3957). + */ + label?: string; type: string; required?: boolean; readonly?: boolean; @@ -143,6 +134,95 @@ function isMissing(v: unknown): boolean { return v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); } +/** + * What the validator needs in order to speak the caller's language (#3957). + * + * Threaded in from `ExecutionContext.locale` — the field whose contract already + * reads "Drives message catalogs and number/date formatting", resolved once per + * request from the `localization` settings (ADR-0053 Phase 2). Nothing here is + * required: with no context the messages render in `en` exactly as they did + * before, so a programmatic / bare-kernel caller is unaffected. + */ +export interface ValidationMessageContext { + /** BCP-47 locale of the principal performing the write. */ + locale?: string; + /** + * `II18nService.t`-compatible lookup, used for two things: a deployment's + * `validation.field.*` message override, and the field's TRANSLATED label + * (the declared `label` is only the source language). + */ + translate?: ValidationMessageTranslator; + /** Object name — needed to address the field's label in the bundle. */ + objectName?: string; +} + +/** + * The field's display name in the caller's locale: translation bundle → + * declared `label` → API name. The API name is the last resort precisely + * because surfacing it is the bug (#3957); it stays available to clients as + * `FieldValidationError.field`. + */ +export function resolveFieldLabel( + name: string, + def: { label?: string } | undefined, + ctx: ValidationMessageContext | undefined, +): string { + if (ctx?.translate && ctx.objectName && ctx.locale) { + const key = objectFieldLabelKey(ctx.objectName, name); + try { + const translated = ctx.translate(key, ctx.locale); + // II18nService echoes the key back on a miss. + if (typeof translated === 'string' && translated.length > 0 && translated !== key) { + return translated; + } + } catch { + // A misbehaving i18n service must not turn a 400 into a 500. + } + } + const declared = def?.label?.trim(); + return declared && declared.length > 0 ? declared : name; +} + +/** + * Build one per-field error: the machine triple (`code` + `params` + `field`) + * plus its rendering in the caller's locale. + * + * `messageKey` defaults to `code` and is only set explicitly where one wire code + * needs more than one sentence (a multiselect's `invalid_option` names the + * offending element; a `datetime` reads differently from a `date`). + * + * Exported because the object-level rule evaluator (`rule-validator.ts`) emits + * into the SAME envelope and must localize its built-in messages the same way — + * two constructors would drift. + */ +export function buildFieldError( + args: { + field: string; + code: FieldValidationCode; + /** Field definition, for its declared `label`. */ + def?: { label?: string }; + params?: FieldValidationParams; + /** Catalog key; defaults to `code`. */ + messageKey?: string; + options?: string[]; + }, + ctx?: ValidationMessageContext, +): FieldValidationError { + const label = resolveFieldLabel(args.field, args.def, ctx); + const message = renderValidationMessage( + { messageKey: args.messageKey ?? args.code, label, field: args.field, params: args.params }, + { locale: ctx?.locale, translate: ctx?.translate }, + ); + return { + field: args.field, + code: args.code, + message, + label, + ...(args.params && Object.keys(args.params).length > 0 ? { params: args.params } : {}), + ...(args.options ? { options: args.options } : {}), + }; +} + function optionValues(options: FieldDef['options']): string[] { if (!Array.isArray(options)) return []; return options.map((o) => @@ -231,13 +311,21 @@ function validateOne( value: unknown, skipRequired = false, mediaStrict = false, + ctx?: ValidationMessageContext, ): FieldValidationError | null { + const fail = ( + code: FieldValidationCode, + params?: FieldValidationParams, + messageKey?: string, + options?: string[], + ) => buildFieldError({ field: name, code, def, params, messageKey, options }, ctx); + // ── required ──────────────────────────────────────────────────── // `autonumber` is runtime-owned: the value is generated by the engine / // driver (the SQL driver assigns it from a persistent sequence AFTER this // validation runs), so a missing value is never a client error — see #1603. if (!skipRequired && def.required && isMissing(value) && def.type !== 'autonumber') { - return { field: name, code: 'required', message: `${name} is required` }; + return fail('required'); } if (isMissing(value)) return null; // nothing else to check @@ -247,19 +335,19 @@ function validateOne( if (t === 'text' || t === 'textarea' || t === 'email' || t === 'url' || t === 'phone' || t === 'password' || t === 'markdown' || t === 'html' || t === 'richtext' || t === 'code') { const s = typeof value === 'string' ? value : String(value); if (def.maxLength !== undefined && s.length > def.maxLength) { - return { field: name, code: 'max_length', message: `${name} must be ≤ ${def.maxLength} characters (got ${s.length})` }; + return fail('max_length', { maxLength: def.maxLength, actual: s.length }); } if (def.minLength !== undefined && s.length < def.minLength) { - return { field: name, code: 'min_length', message: `${name} must be ≥ ${def.minLength} characters (got ${s.length})` }; + return fail('min_length', { minLength: def.minLength, actual: s.length }); } if (t === 'email' && !EMAIL_RE.test(s)) { - return { field: name, code: 'invalid_email', message: `${name} must be a valid email address` }; + return fail('invalid_email'); } if (t === 'url' && !URL_RE.test(s)) { - return { field: name, code: 'invalid_url', message: `${name} must be a valid URL (scheme://...)` }; + return fail('invalid_url'); } if (t === 'phone' && !PHONE_RE.test(s)) { - return { field: name, code: 'invalid_phone', message: `${name} must be a valid phone number` }; + return fail('invalid_phone'); } return null; } @@ -268,13 +356,13 @@ function validateOne( if (t === 'number' || t === 'currency' || t === 'percent' || t === 'rating' || t === 'slider') { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) { - return { field: name, code: 'invalid_number', message: `${name} must be a number` }; + return fail('invalid_number'); } if (def.min !== undefined && n < def.min) { - return { field: name, code: 'min_value', message: `${name} must be ≥ ${def.min}` }; + return fail('min_value', { min: def.min }); } if (def.max !== undefined && n > def.max) { - return { field: name, code: 'max_value', message: `${name} must be ≤ ${def.max}` }; + return fail('max_value', { max: def.max }); } return null; } @@ -283,14 +371,15 @@ function validateOne( if (t === 'boolean' || t === 'toggle') { if (typeof value === 'boolean') return null; if (value === 0 || value === 1 || value === '0' || value === '1' || value === 'true' || value === 'false') return null; - return { field: name, code: 'invalid_boolean', message: `${name} must be true or false` }; + return fail('invalid_boolean'); } // ── date/datetime ─────────────────────────────────────────────── if (t === 'date' || t === 'datetime') { if (value instanceof Date) return null; if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null; - return { field: name, code: 'invalid_date', message: `${name} must be a valid ${t} (ISO-8601)` }; + // Same wire code, two sentences: "a valid date" vs "a valid datetime". + return fail('invalid_date', { type: t }, t === 'datetime' ? 'invalid_datetime' : 'invalid_date'); } // ── time (time-of-day) ────────────────────────────────────────── @@ -309,7 +398,7 @@ function validateOne( const hasDate = /\d{4}-\d{2}-\d{2}/.test(value); if (timeOfDay.test(value.trim()) || (hasDate && !Number.isNaN(Date.parse(value)))) return null; } - return { field: name, code: 'invalid_time', message: `${name} must be a valid time (HH:MM or HH:MM:SS)` }; + return fail('invalid_time'); } // ── select / radio (single-value) ─────────────────────────────── @@ -320,7 +409,7 @@ function validateOne( if ((t === 'select' || t === 'radio') && def.multiple !== true) { const allowed = optionValues(def.options); if (allowed.length > 0 && !allowed.includes(String(value))) { - return { field: name, code: 'invalid_option', message: `${name} must be one of: ${allowed.join(', ')}`, options: allowed }; + return fail('invalid_option', { allowed: allowed.join(', ') }, 'invalid_option', allowed); } return null; } @@ -331,7 +420,7 @@ function validateOne( // storing it verbatim corrupts the column for every array-consumer (#2552). if (isMultiValueField(def)) { if (!Array.isArray(value)) { - return { field: name, code: 'invalid_type', message: `${name} must be an array of values` }; + return fail('invalid_type', undefined, 'invalid_type_array'); } // Reference / attachment types carry IDs or storage keys, not options — // reference integrity is handled elsewhere. @@ -340,7 +429,12 @@ function validateOne( if (allowed.length === 0) return null; // free-form (tags without options) for (const v of value) { if (!allowed.includes(String(v))) { - return { field: name, code: 'invalid_option', message: `${name}: "${v}" is not one of: ${allowed.join(', ')}`, options: allowed }; + return fail( + 'invalid_option', + { value: String(v), allowed: allowed.join(', ') }, + 'invalid_option_value', + allowed, + ); } } return null; @@ -371,11 +465,14 @@ function validateOne( const parsed = shapeSchemaFor(def).safeParse(value); if (!parsed.success) { const detail = parsed.error.issues[0]?.message ?? 'invalid value shape'; - const message = `${name} has an invalid ${t} value: ${detail}`; const isMedia = FILE_REFERENCE_TYPES.has(t); if (isMedia ? mediaStrictEffective(mediaStrict) : VALUE_SHAPE_STRICT()) { - return { field: name, code: 'invalid_type', message }; + return fail('invalid_type', { type: t, detail }, 'invalid_value_shape'); } + // The warn-first path is a DEVELOPER log line, not an end-user message — + // it names the API field and stays English so it greps the same in every + // deployment's logs. + const message = `${name} has an invalid ${t} value: ${detail}`; warnOnce( `${t}:${name}`, `[value-shape] ${message} — accepted for now (ADR-0104 warn-first; ` + @@ -458,6 +555,13 @@ export interface ValidateRecordOptions { * because the evidence was unavailable. */ mediaValueShapeStrict?: boolean; + + /** + * Locale + translation hooks for the human half of each error (#3957). Omit + * and messages render in `en` against the declared labels — the pre-#3957 + * behavior for any caller that has no principal to read a locale from. + */ + messages?: ValidationMessageContext; } /** @@ -478,6 +582,7 @@ export function validateRecord( const errors: FieldValidationError[] = []; const fields = objectSchema.fields; const mediaStrict = options.mediaValueShapeStrict === true; + const messages = options.messages; if (mode === 'insert') { // Walk all declared fields — required check applies even when @@ -485,7 +590,7 @@ export function validateRecord( for (const [name, def] of Object.entries(fields)) { if (SKIP_FIELDS.has(name)) continue; if (def.system || def.readonly) continue; - const err = validateOne(name, def, data[name], false, mediaStrict); + const err = validateOne(name, def, data[name], false, mediaStrict, messages); if (err) errors.push(err); } } else { @@ -498,7 +603,7 @@ export function validateRecord( // skipRequired: PATCH-omitted fields must not 400. (No def clone — the // registry's own field object flows through so the ADR-0104 value-shape // schema cache, keyed on def identity, hits.) - const err = validateOne(name, def, value, true, mediaStrict); + const err = validateOne(name, def, value, true, mediaStrict, messages); if (err) errors.push(err); } } diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 1e45d3d606..9692b0dbbd 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -793,3 +793,111 @@ describe('conditional enforcement', () => { expect(() => evaluateValidationRules(broken, { account_type: 'enterprise', approver: null }, 'insert')).not.toThrow(); }); }); + +/** + * #3957 — this evaluator's BUILT-IN messages had the same defect as the field + * validator's: hardcoded English with the API field name. An author-written + * `rule.message` is untouched (it is already in the author's language); only + * the platform's own sentences are localized. + */ +describe('evaluateValidationRules — built-in messages are localized (#3957)', () => { + const zh = { locale: 'zh-CN', objectName: 'mes_invoice' }; + + const failing = ( + schema: any, + data: Record, + mode: 'insert' | 'update', + opts: Record = {}, + ) => { + try { + evaluateValidationRules(schema, data, mode, opts); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + it('localizes a `requiredWhen` violation and names the field by its label', () => { + const schema = { + fields: { + status: { type: 'select', label: '状态' }, + amount: { type: 'currency', label: '金额', requiredWhen: 'record.status == "sent"' }, + }, + }; + const [err] = failing(schema, { status: 'sent' }, 'insert', { messages: zh }); + expect(err.message).toBe('金额不能为空'); + expect(err).toMatchObject({ field: 'amount', code: 'required', label: '金额' }); + }); + + it('localizes an option-visibility rejection', () => { + const schema = { + fields: { + tier: { + type: 'select', + label: '等级', + options: [ + { value: 'basic' }, + { value: 'vip', visibleWhen: 'record.approved == true' }, + ], + }, + }, + }; + const [err] = failing(schema, { tier: 'vip', approved: false }, 'insert', { messages: zh }); + expect(err.message).toBe('等级:选项“vip”当前不可用'); + expect(err.code).toBe('invalid_option'); + }); + + it('localizes the state-machine fallbacks when a rule declares no message', () => { + const schema = { + fields: { status: { type: 'select', label: '状态' } }, + validations: [{ + type: 'state_machine', + name: 'lifecycle', + message: '', + field: 'status', + initialStates: ['draft'], + transitions: { draft: ['sent'], sent: ['paid'] }, + }], + }; + expect(failing(schema, { status: 'paid' }, 'insert', { messages: zh })[0].message) + .toBe('状态的初始状态无效:paid(允许:draft)'); + // draft → paid is not in `transitions.draft` (only `sent` is). + expect( + failing(schema, { status: 'paid' }, 'update', { messages: zh, previous: { status: 'draft' } })[0].message, + ).toBe('状态不允许从 draft 变更为 paid'); + }); + + /** + * An author who wrote their own message owns the wording — localizing over it + * would silently replace their (often already-translated) text. + */ + it('never overrides an author-written rule message', () => { + const schema = { + fields: { status: { type: 'select', label: '状态' } }, + validations: [{ + type: 'state_machine', + name: 'lifecycle', + message: '已付款的发票不能退回草稿。', + field: 'status', + transitions: { paid: [] }, + }], + }; + const [err] = failing(schema, { status: 'draft' }, 'update', { + messages: zh, + previous: { status: 'paid' }, + }); + expect(err.message).toBe('已付款的发票不能退回草稿。'); + // …but the field still carries its localized label for the UI. + expect(err.label).toBe('状态'); + }); + + it('renders English against the declared label when no locale is supplied', () => { + const schema = { + fields: { + status: { type: 'select', label: 'Status' }, + amount: { type: 'currency', label: 'Amount', requiredWhen: 'record.status == "sent"' }, + }, + }; + expect(failing(schema, { status: 'sent' }, 'insert')[0].message).toBe('Amount is required'); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index ede6bc39bb..0ff872aaf1 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -78,7 +78,13 @@ import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import Ajv, { type ValidateFunction } from 'ajv'; -import { ValidationError, type FieldValidationError } from './record-validator.js'; +import { + ValidationError, + buildFieldError, + resolveFieldLabel, + type FieldValidationError, + type ValidationMessageContext, +} from './record-validator.js'; type Mode = 'insert' | 'update'; @@ -140,6 +146,10 @@ interface RuleContext { previous: Record | undefined; mode: Mode; logger: EvaluateRulesOptions['logger']; + /** Declared fields — the source of a violation's display label (#3957). */ + fields: Record | undefined; + /** Locale + translation hooks for the BUILT-IN messages (#3957). */ + messages: ValidationMessageContext | undefined; } /** @@ -174,6 +184,13 @@ export interface EvaluateRulesOptions { * `script`, `json_schema`, `conditional`). */ skipStateMachine?: boolean; + /** + * Locale + translation hooks for this evaluator's BUILT-IN messages (#3957) — + * the `requiredWhen` required-check, per-option gating, and the state-machine + * fallbacks. An author-written `rule.message` is never touched: it is already + * in whatever language its author chose. + */ + messages?: ValidationMessageContext; } /** @@ -420,6 +437,8 @@ interface ConditionalFieldOption { } interface ConditionalFieldDef { + /** Author-declared display label, used to name the field in messages (#3957). */ + label?: string; requiredWhen?: string | Expression; // Retired authorable key. #3754 lowered it into `requiredWhen` and dropped it, // so PARSED metadata never carried it; #3855 removed it from the spec outright, @@ -506,6 +525,7 @@ function evaluateOptionVisibility( currentUser: EvaluateRulesOptions['currentUser'], errors: FieldValidationError[], logger: EvaluateRulesOptions['logger'], + messages: ValidationMessageContext | undefined, ): void { if (!fields) return; const user = (currentUser ?? undefined) as any; @@ -534,11 +554,13 @@ function evaluateOptionVisibility( continue; // fail-open } if (res.value === false) { - errors.push({ + errors.push(buildFieldError({ field: name, code: 'invalid_option', - message: `${name}: option '${String(value)}' is not available`, - }); + def, + params: { value: String(value) }, + messageKey: 'option_unavailable', + }, messages)); } } } @@ -578,7 +600,7 @@ export function evaluateValidationRules( if (!(name in merged)) merged[name] = null; } } - const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger }; + const ctx: RuleContext = { data, merged, previous, mode, logger: opts.logger, fields, messages: opts.messages }; const errors: FieldValidationError[] = []; @@ -597,7 +619,7 @@ export function evaluateValidationRules( continue; } if (res.value === true && isMissing(merged[name])) { - errors.push({ field: name, code: 'required', message: `${name} is required` }); + errors.push(buildFieldError({ field: name, code: 'required', def }, opts.messages)); } } } @@ -606,7 +628,7 @@ export function evaluateValidationRules( // choice value whose option `visibleWhen` resolves cleanly to FALSE against the // merged record + `current_user`. Complements the client-side hiding, which is // not a security boundary. - evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, errors, opts.logger); + evaluateOptionVisibility(fields, data, merged, previous, opts.currentUser, errors, opts.logger, opts.messages); const ordered = (hasRules ? rules! : []) .filter((r): r is BaseRule => r != null && typeof r === 'object') @@ -657,7 +679,7 @@ export function evaluateValidationRules( function evaluateRule(rule: BaseRule, ctx: RuleContext): FieldValidationError | null { switch (rule.type) { case 'state_machine': - return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous); + return checkStateMachine(rule as StateMachineRule, ctx.mode, ctx.data, ctx.previous, ctx); case 'script': case 'cross_field': return checkPredicate(rule as PredicateRule, ctx.merged, ctx.previous, ctx.logger); @@ -691,7 +713,25 @@ function checkStateMachine( mode: Mode, data: Record, previous: Record | undefined, + ctx?: Pick, ): FieldValidationError | null { + // An author-written `rule.message` wins untouched — it is already in the + // language its author chose. Only the FALLBACK is ours to localize (#3957). + const fallback = ( + code: 'invalid_initial_state' | 'invalid_transition', + params: Record, + ): FieldValidationError => { + const def = ctx?.fields?.[rule.field]; + if (rule.message) { + return { + field: rule.field, + code, + message: rule.message, + label: resolveFieldLabel(rule.field, def, ctx?.messages), + }; + } + return buildFieldError({ field: rule.field, code, def, params }, ctx?.messages); + }; if (mode === 'insert') { const initial = rule.initialStates; if (!Array.isArray(initial) || initial.length === 0) return null; // no initial-state contract → legacy no-op @@ -699,13 +739,10 @@ function checkStateMachine( const value = data[rule.field]; if (value === undefined || value === null || value === '') return null; // empty → not an initial state to check if (!initial.includes(String(value))) { - return { - field: rule.field, - code: 'invalid_initial_state', - message: - rule.message || - `Invalid initial state for ${rule.field}: ${String(value)} (allowed: ${initial.join(', ')})`, - }; + return fallback('invalid_initial_state', { + value: String(value), + allowed: initial.join(', '), + }); } return null; } @@ -725,13 +762,7 @@ function checkStateMachine( if (!Array.isArray(allowed)) return null; if (!allowed.includes(String(to))) { - return { - field: rule.field, - code: 'invalid_transition', - message: - rule.message || - `Invalid transition for ${rule.field}: ${fromKey} → ${String(to)}`, - }; + return fallback('invalid_transition', { from: fromKey, to: String(to) }); } return null; } diff --git a/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts new file mode 100644 index 0000000000..c41c5a0333 --- /dev/null +++ b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts @@ -0,0 +1,181 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Localized field-validation messages, end-to-end through the real stack: +// HTTP request (`Accept-Language`) → REST exec-context → ObjectQL engine → +// record-validator → `400 VALIDATION_FAILED` body → what the Console toast and +// the CSV-import row report display. +// +// #3957: the write path built each message by concatenating the API field name +// into a hardcoded English template, so a Chinese-locale user importing a bad row +// read, verbatim in the UI: +// +// 第 1 行:penalty_amount must be ≥ 0 +// +// …for a field declared `label: '处罚金额'`. The form layer localized the SAME +// constraint correctly (the browser's native `min`), so the language flipped +// depending on which layer caught the value. These tests pin the whole chain: +// the message is in the caller's language, names the field the way the user +// knows it, and carries the constraint as data so a custom client can format its +// own text. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// Mirrors the reporting object from the issue: Chinese labels, range-guarded +// settlement amounts, a bounded remark. +const VmlSettlement = ObjectSchema.create({ + name: 'vml_settlement', + label: '结算单', + fields: { + name: Field.text({ label: '名称', required: true }), + penalty_amount: Field.currency({ label: '处罚金额', min: 0 }), + copq: Field.currency({ label: '质量成本', max: 99999999.99 }), + quota_hours: Field.number({ label: '定额工时', min: 0 }), + remark: Field.text({ label: '备注', maxLength: 512 }), + }, +}); + +const vmlStack = defineStack({ + manifest: { + id: 'com.dogfood.validation_message_locale', + namespace: 'vml', + version: '0.0.0', + type: 'app', + name: 'Validation Message Locale Fixture', + description: 'Range-guarded settlement fields with Chinese labels (#3957).', + }, + objects: [VmlSettlement], +}); + +describe('objectstack verify: localized field-validation messages (#3957)', () => { + let stack: VerifyStack; + let token: string; + + /** An authed JSON request that expresses a language preference. */ + const apiIn = (locale: string | undefined, method: string, path: string, body?: unknown) => + stack.api(path, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(locale ? { 'Accept-Language': locale } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + const reject = async (locale: string | undefined, body: unknown) => { + const r = await apiIn(locale, 'POST', '/data/vml_settlement', body); + expect(r.status, `expected a 400, got ${r.status}`).toBe(400); + return (await r.json()) as any; + }; + + beforeAll(async () => { + stack = await bootStack(vmlStack); + token = await stack.signIn(); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('a zh-CN caller reads Chinese, and never the API column name', async () => { + const body = await reject('zh-CN', { name: 'S1', penalty_amount: -1 }); + // What the Console toast shows verbatim. + expect(body.error).toBe('处罚金额必须大于或等于 0'); + expect(JSON.stringify(body)).not.toContain('must be'); + expect(body.fields[0].message).toBe('处罚金额必须大于或等于 0'); + // The API name stays in `field` so a form can focus the right input. + expect(body.fields[0].field).toBe('penalty_amount'); + expect(body.fields[0].message).not.toContain('penalty_amount'); + }); + + it('carries label + code + params so a client can format its own text', async () => { + const body = await reject('zh-CN', { name: 'S1', remark: 'x'.repeat(3000) }); + expect(body.code).toBe('VALIDATION_FAILED'); + expect(body.fields[0]).toMatchObject({ + field: 'remark', + code: 'max_length', + label: '备注', + params: { maxLength: 512, actual: 3000 }, + }); + expect(body.fields[0].message).toBe('备注长度不能超过 512 个字符(当前 3000 个)'); + }); + + it('every range guard from the issue is localized', async () => { + expect((await reject('zh-CN', { name: 'S', copq: 1e12 })).error) + .toBe('质量成本必须小于或等于 99999999.99'); + expect((await reject('zh-CN', { name: 'S', quota_hours: -3 })).error) + .toBe('定额工时必须大于或等于 0'); + // required, on the same object + expect((await reject('zh-CN', { penalty_amount: 1 })).error).toBe('名称不能为空'); + }); + + /** + * The sentence and the field name resolve independently: the TEMPLATE comes + * from the requested locale's catalog, the LABEL from the bundle (or, with no + * translation for that locale, the declared label). This fixture ships only + * Chinese labels, so a `ja-JP` request yields a Japanese sentence around the + * declared Chinese label — correct, and proof the two are separate lookups. + */ + it('follows the request locale, not a single server-wide language', async () => { + expect((await reject('ja-JP', { name: 'S', quota_hours: -3 })).error) + .toBe('定额工时は 0 以上で入力してください'); + expect((await reject('es-ES', { name: 'S', quota_hours: -3 })).error) + .toBe('定额工时 debe ser mayor o igual que 0'); + // A base-language tag and a weighted header both resolve. + expect((await reject('zh', { name: 'S', quota_hours: -3 })).error) + .toBe('定额工时必须大于或等于 0'); + expect((await reject('zh-CN,zh;q=0.9,en;q=0.8', { name: 'S', quota_hours: -3 })).error) + .toBe('定额工时必须大于或等于 0'); + }); + + /** + * No stated preference → the workspace default (`en-US`). The template is + * English again, but the field is STILL named by its label: even the + * unlocalized path stops leaking `quota_hours` at end users. + */ + it('a caller with no language preference gets English against the label', async () => { + const body = await reject(undefined, { name: 'S', quota_hours: -3 }); + expect(body.error).toBe('定额工时 must be ≥ 0'); + expect(body.error).not.toContain('quota_hours'); + }); + + it('PATCH is localized too, not only create', async () => { + const created = await apiIn('zh-CN', 'POST', '/data/vml_settlement', { name: 'S-patch' }); + expect(created.status).toBeLessThan(300); + const id = ((await created.json()) as any).record?.id ?? ((await created.clone().json()) as any).id; + expect(id).toBeTruthy(); + + const r = await apiIn('zh-CN', 'PATCH', `/data/vml_settlement/${id}`, { quota_hours: -1 }); + expect(r.status).toBe(400); + expect(((await r.json()) as any).error).toBe('定额工时必须大于或等于 0'); + }); + + /** + * The issue's actual repro: a CSV/JSON import row report. Both the engine's + * constraint messages and the importer's own cell-coercion messages land in + * the same report, so both must speak the caller's language. + */ + it('the import row report is localized — constraint AND cell coercion', async () => { + const r = await apiIn('zh-CN', 'POST', '/data/vml_settlement/import', { + format: 'json', + runAutomations: false, + rows: [ + { name: '越界行', penalty_amount: -1 }, // engine constraint + { name: '烂单元格', quota_hours: 'abc' }, // importer coercion + { name: '好行', penalty_amount: 10 }, + ], + }); + expect(r.status).toBeLessThan(300); + const body = (await r.json()) as any; + const failed = body.results.filter((x: any) => !x.ok); + expect(failed).toHaveLength(2); + expect(failed[0].error).toBe('处罚金额必须大于或等于 0'); + expect(failed[1].error).toBe('定额工时:“abc”不是有效的数字'); + // The row report never shows an API column name. + expect(JSON.stringify(failed)).not.toContain('penalty_amount:'); + expect(JSON.stringify(failed)).not.toContain('quota_hours:'); + }); +}); diff --git a/packages/rest/src/import-coerce.test.ts b/packages/rest/src/import-coerce.test.ts index 740af7c283..b1aa22b59b 100644 --- a/packages/rest/src/import-coerce.test.ts +++ b/packages/rest/src/import-coerce.test.ts @@ -272,3 +272,68 @@ describe('coerceRow', () => { expect(data).toEqual({ mystery: 'raw' }); }); }); + +/** + * #3957 — the importer's row report is where a user meets these messages, and + * it used to be hardcoded English naming the API column + * (`penalty_amount: "abc" is not a number`). The engine's validation errors in + * the SAME report are localized, so these must be too, from the same catalog. + */ +describe('coerceRow — cell-coercion messages are localized (#3957)', () => { + const meta = new Map([ + ['penalty_amount', { name: 'penalty_amount', type: 'currency', label: '处罚金额' }], + ['is_active', { name: 'is_active', type: 'boolean', label: '启用' }], + ['due_at', { name: 'due_at', type: 'datetime', label: '截止时间' }], + ['stage', { name: 'stage', type: 'select', label: '阶段', options: [{ label: '草稿', value: 'draft' }] }], + ['owner', { name: 'owner', type: 'lookup', label: '负责人', reference: 'sys_user' }], + ]); + + const firstError = async (row: Record, locale?: string) => { + const { errors } = await coerceRow(row, meta, { + locale, + resolveRef: async () => undefined, // nothing ever matches + }); + expect(errors).toHaveLength(1); + return errors[0]; + }; + + it('names the column by its label and keeps the machine code', async () => { + const err = await firstError({ penalty_amount: 'abc' }, 'zh-CN'); + expect(err.message).toBe('处罚金额:“abc”不是有效的数字'); + expect(err).toMatchObject({ field: 'penalty_amount', code: 'invalid_number' }); + }); + + it('localizes every coercion failure kind', async () => { + expect((await firstError({ is_active: 'maybe' }, 'zh-CN')).message) + .toBe('启用:“maybe”不是有效的布尔值'); + expect((await firstError({ due_at: 'not-a-date' }, 'zh-CN')).message) + .toBe('截止时间:“not-a-date”不是有效的日期时间'); + expect((await firstError({ stage: '归档' }, 'zh-CN')).message) + .toBe('阶段:“归档”不是可选值之一'); + expect((await firstError({ owner: '查无此人' }, 'zh-CN')).message) + .toBe('负责人:未找到与“查无此人”匹配的记录'); + }); + + it('renders English against the label when no locale is supplied', async () => { + const err = await firstError({ penalty_amount: 'abc' }); + expect(err.message).toBe('处罚金额: "abc" is not a number'); + }); + + /** + * The reference message no longer leaks the target object's API name + * (`no sys_user matches "…"`) — naming internal identifiers is the defect this + * issue is about, and the column plus the offending value are what an importer + * can act on. + */ + it('does not leak the referenced object’s API name', async () => { + const err = await firstError({ owner: '查无此人' }, 'zh-CN'); + expect(err.message).not.toContain('sys_user'); + expect(err.message).toContain('查无此人'); + }); + + it('falls back to the column name when no label is declared', async () => { + const bare = new Map([['qty', { name: 'qty', type: 'number' }]]); + const { errors } = await coerceRow({ qty: 'abc' }, bare, { locale: 'zh-CN' }); + expect(errors[0].message).toBe('qty:“abc”不是有效的数字'); + }); +}); diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index f12204f4ba..8205813c34 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -41,6 +41,10 @@ import { REFERENCE_VALUE_TYPES, isMultiValueField as specIsMultiValueField, } from '@objectstack/spec/data'; +import { + renderValidationMessage, + type ValidationMessageTranslator, +} from '@objectstack/spec/system'; /** * Field types whose stored value points at another record (id). The spec's @@ -107,6 +111,14 @@ export interface CoerceContext { createMissingOptions?: boolean; /** Async reference resolver (name/email/id → record id). Optional. */ resolveRef?: RefResolver; + /** + * Locale of the importing principal (`ExecutionContext.locale`). Cell-coercion + * failures land in the same row report as the engine's validation errors, so + * they are localized from the same catalog (#3957). Absent → `en`. + */ + locale?: string; + /** `II18nService.t`-compatible lookup for message overrides (#3957). */ + translate?: ValidationMessageTranslator; } /** A per-field coercion failure, shaped like the engine's validation errors. */ @@ -116,6 +128,34 @@ export interface FieldCoerceError { message: string; } +/** + * Build a coercion failure whose message names the column by its (localized) + * label and quotes the offending cell — never the API field name (#3957). + * + * `code` stays the machine identity the importer's row report and its tests key + * off; `messageKey` selects the sentence from the shared catalog. + */ +function coerceError( + meta: ExportFieldMeta | undefined, + field: string, + code: string, + messageKey: string, + value: unknown, + ctx: CoerceContext, +): { error: FieldCoerceError } { + const label = meta?.label?.trim() || field; + return { + error: { + field, + code, + message: renderValidationMessage( + { messageKey, label, field, params: { value: String(value) } }, + { locale: ctx.locale, translate: ctx.translate }, + ), + }, + }; +} + // ── blank / null handling ────────────────────────────────────────── function isBlank(value: unknown, nullValues?: string[]): boolean { @@ -284,19 +324,23 @@ export async function coerceFieldValue( if (BOOL_TYPES.has(t)) { const b = parseBooleanCell(raw); - if (b === undefined) return { error: { field, code: 'invalid_boolean', message: `${field}: "${String(raw)}" is not a boolean` } }; + if (b === undefined) return coerceError(meta, field, 'invalid_boolean', 'import_invalid_boolean', raw, ctx); return { value: b }; } if (NUMBER_TYPES.has(t)) { const n = parseNumberCell(raw); - if (n === undefined) return { error: { field, code: 'invalid_number', message: `${field}: "${String(raw)}" is not a number` } }; + if (n === undefined) return coerceError(meta, field, 'invalid_number', 'import_invalid_number', raw, ctx); return { value: n }; } if (t === 'date' || t === 'datetime' || t === 'time') { const d = parseDateCell(raw, t); - if (d === undefined) return { error: { field, code: 'invalid_date', message: `${field}: "${String(raw)}" is not a valid ${t}` } }; + if (d === undefined) { + // One code, three sentences — a `time` cell is not "not a valid date". + const key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date'; + return coerceError(meta, field, 'invalid_date', key, raw, ctx); + } return { value: d }; } @@ -312,7 +356,7 @@ export async function coerceFieldValue( const v = matchOption(part, meta?.options); if (v === undefined) { if (ctx.createMissingOptions) { out.push(part); continue; } - return { error: { field, code: 'invalid_option', message: `${field}: "${part}" is not a known option` } }; + return coerceError(meta, field, 'invalid_option', 'import_unknown_option', part, ctx); } out.push(v); } @@ -321,7 +365,7 @@ export async function coerceFieldValue( const v = matchOption(raw, meta?.options); if (v === undefined) { if (ctx.createMissingOptions) return { value: String(raw).trim() }; - return { error: { field, code: 'invalid_option', message: `${field}: "${String(raw)}" is not a known option` } }; + return coerceError(meta, field, 'invalid_option', 'import_unknown_option', raw, ctx); } return { value: v }; } @@ -340,10 +384,10 @@ export async function coerceFieldValue( for (const token of tokens) { const m = normalizeRefMatch(await ctx.resolveRef(meta.reference, token, meta)); if (m.ambiguous) { - return { error: { field, code: 'reference_ambiguous', message: `${field}: "${token}" matches more than one ${meta.reference} — use a unique value or the record id` } }; + return coerceError(meta, field, 'reference_ambiguous', 'import_reference_ambiguous', token, ctx); } if (m.id === undefined) { - return { error: { field, code: 'reference_not_found', message: `${field}: no ${meta.reference} matches "${token}"` } }; + return coerceError(meta, field, 'reference_not_found', 'import_reference_not_found', token, ctx); } out.push(m.id); } @@ -356,10 +400,10 @@ export async function coerceFieldValue( if (!ctx.resolveRef || !meta?.reference) return { value: display }; const match = normalizeRefMatch(await ctx.resolveRef(meta.reference, display, meta)); if (match.ambiguous) { - return { error: { field, code: 'reference_ambiguous', message: `${field}: "${display}" matches more than one ${meta.reference} — use a unique value or the record id` } }; + return coerceError(meta, field, 'reference_ambiguous', 'import_reference_ambiguous', display, ctx); } if (match.id === undefined) { - return { error: { field, code: 'reference_not_found', message: `${field}: no ${meta.reference} matches "${display}"` } }; + return coerceError(meta, field, 'reference_not_found', 'import_reference_not_found', display, ctx); } return { value: match.id }; } diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 2281804a88..ce8e1925c0 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -408,11 +408,13 @@ describe('import route — required-field dry-run fidelity', () => { expect(dry._json.results.find((r: any) => !r.ok)).toMatchObject({ row: 1, field: 'status', code: 'required' }); expect(await engine.findOne('member', { where: { id: 'm2' } })).toBeNull(); // dry run never writes - // Real insert: SAME verdict (parity), and a readable `status is required` - // instead of a raw `NOT NULL constraint failed: member.status`. + // Real insert: SAME verdict (parity), and a readable required message + // instead of a raw `NOT NULL constraint failed: member.status`. The field is + // named by its declared LABEL, not the API name (#3957) — `status` is + // declared `label: 'Status'` in this fixture. const real = await imp({ format: 'json', runAutomations: false, rows }); expect(real._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 }); - expect(real._json.results.find((r: any) => !r.ok)).toMatchObject({ field: 'status', code: 'required', error: 'status is required' }); + expect(real._json.results.find((r: any) => !r.ok)).toMatchObject({ field: 'status', code: 'required', error: 'Status is required' }); expect((await engine.findOne('member', { where: { id: 'm2' } }))?.status).toBe('active'); expect(await engine.findOne('member', { where: { id: 'm1' } })).toBeNull(); }); diff --git a/packages/rest/src/import-prepare.ts b/packages/rest/src/import-prepare.ts index c3434164d5..708a208733 100644 --- a/packages/rest/src/import-prepare.ts +++ b/packages/rest/src/import-prepare.ts @@ -231,6 +231,25 @@ export function mergeLocalizedOptionSynonyms( } } +/** + * Overwrite each column's `label` with the locale-translated one (#3957). + * + * The row report names a failing column by `meta.label`, and `buildFieldMetaMap` + * reads the AUTHORED schema — so an app authored in English with a `zh-CN` + * bundle reported `Account: 未找到…`: a localized sentence around an English + * column name. The translated document is already fetched here for option + * synonyms; this reads its labels from the same place. + */ +export function applyLocalizedFieldLabels( + metaMap: Map, + localized: Map, +): void { + for (const [name, meta] of metaMap) { + const label = localized.get(name)?.label; + if (typeof label === 'string' && label.trim().length > 0) meta.label = label; + } +} + export async function prepareImportRequest( body: any, opts: { @@ -390,7 +409,11 @@ export async function prepareImportRequest( try { const localized = await localizeSchema(schema); if (localized && localized !== schema) { - mergeLocalizedOptionSynonyms(metaMap, buildFieldMetaMap(localized)); + const localizedMeta = buildFieldMetaMap(localized); + mergeLocalizedOptionSynonyms(metaMap, localizedMeta); + // The row report names columns by label — it must be the + // reader's label, not the authored one (#3957). + applyLocalizedFieldLabels(metaMap, localizedMeta); } } catch { /* authored-only option matching */ } } diff --git a/packages/rest/src/import-runner.ts b/packages/rest/src/import-runner.ts index 9157fa73a3..edb9afbc80 100644 --- a/packages/rest/src/import-runner.ts +++ b/packages/rest/src/import-runner.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'; import { coerceRow, firstMissingRequiredField, type RefResolver, type RefMatch } from './import-coerce.js'; import type { ExportFieldMeta } from './export-format.js'; +import { renderValidationMessage, type ValidationMessageTranslator } from '@objectstack/spec/system'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core'; /** @@ -136,6 +137,13 @@ export interface RunImportOptions { * stored snapshot size. */ captureUndo?: boolean; + /** + * `II18nService.t`-compatible lookup so this runner's own messages (the + * required-field pre-check, cell coercion) resolve a deployment's + * `validation.field.*` overrides — the same hook the engine gets (#3957). The + * locale itself rides `context.locale`. + */ + translate?: ValidationMessageTranslator; } /** Extracts a created/updated record's id regardless of which response shape the protocol returned. */ @@ -220,7 +228,7 @@ const yieldToEventLoop = (): Promise => export function runImport(opts: RunImportOptions): Promise { const { - p, objectName, environmentId, context, rows, metaMap, + p, objectName, environmentId, context, rows, metaMap, translate: messageTranslator, writeMode, matchFields, dryRun, runAutomations, treatAsHistorical, trimWhitespace, nullValues, createMissingOptions, skipBlankMatchKey, onProgress, shouldCancel, captureUndo, @@ -554,6 +562,9 @@ export function runImport(opts: RunImportOptions): Promise { // 1. Coerce every cell to its storage value (+ resolve lookups). const { data, errors } = await coerceRow(rows[i], metaMap, { trimWhitespace, nullValues, createMissingOptions, resolveRef, + // Cell-coercion failures land in the same row report as the engine's + // validation errors, so they speak the same language (#3957). + locale: context?.locale, translate: messageTranslator, }); if (errors.length > 0) { const first = errors[0]; @@ -595,7 +606,19 @@ export function runImport(opts: RunImportOptions): Promise { if (requiredMiss) { errCount++; - results[i] = { row: rowNo, ok: false, action: 'failed', field: requiredMiss, code: 'required', error: `${requiredMiss} is required` }; + // Same catalog the engine's own required-check renders from, so a + // pre-check verdict and a real rejection read identically (#3957). + results[i] = { + row: rowNo, ok: false, action: 'failed', field: requiredMiss, code: 'required', + error: renderValidationMessage( + { + messageKey: 'required', + label: metaMap.get(requiredMiss)?.label?.trim() || requiredMiss, + field: requiredMiss, + }, + { locale: context?.locale, translate: messageTranslator }, + ), + }; } else if (!willUpdate && !willCreate) { // update mode, no match → skip. skipped++; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 70d86a5959..2a82d4ea57 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -11,6 +11,7 @@ import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpoints import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; import { PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { DroppedFieldsEvent } from '@objectstack/spec/data'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, @@ -1541,6 +1542,15 @@ export class RestServer { } } catch { /* gate is best-effort — never break context resolution */ } + // [#3957] The request's OWN locale wins over the workspace default. + // Metadata (labels, option text) is already translated per + // `Accept-Language` / `?locale` — so if the write path read only the + // workspace `localization.locale`, one screen could show a Chinese + // field label next to an English rejection message for that same + // field. `localization.locale` stays the fallback for a caller that + // expresses no preference (a server-to-server client, a job). + const effectiveLocale = this.extractLocale(req) ?? localization.locale; + const execCtx = { userId: authz.userId, tenantId: authz.tenantId, @@ -1561,7 +1571,7 @@ export class RestServer { accessible_org_ids: authz.accessible_org_ids, ...(authGate ? { authGate } : {}), ...(localization.timezone ? { timezone: localization.timezone } : {}), - ...(localization.locale ? { locale: localization.locale } : {}), + ...(effectiveLocale ? { locale: effectiveLocale } : {}), ...(localization.currency ? { currency: localization.currency } : {}), // Internal: resolved kernel so the nav-serving path can probe // requiresService capability gates (ADR-0057 D10). NOT an @@ -1747,10 +1757,10 @@ export class RestServer { ? headers.get('accept-language') ?? undefined : headers['accept-language'] ?? headers['Accept-Language']; } - if (typeof header === 'string' && header.length > 0) { - const top = header.split(',')[0]?.split(';')[0]?.trim(); - if (top) return top; - } + // Shared parse — the runtime dispatcher resolves the same header the + // same way, so a message and the labels around it can't disagree (#3957). + const preferred = preferredLocaleFromHeader(header); + if (preferred) return preferred; const queryLocale = req?.query?.locale; if (typeof queryLocale === 'string' && queryLocale.length > 0) return queryLocale; if (i18n && typeof i18n.getDefaultLocale === 'function') { @@ -1760,6 +1770,21 @@ export class RestServer { return undefined; } + /** + * An `II18nService.t`-compatible lookup for the request's environment, or + * `undefined` when no i18n service is registered. Handed to the import + * runner so its own messages resolve a deployment's `validation.field.*` + * overrides — the engine gets the same hook via `ObjectQLPlugin` (#3957). + */ + private async resolveMessageTranslator( + environmentId: string | undefined, + req: any, + ): Promise<((key: string, locale: string, params?: Record) => string) | undefined> { + const i18n = await this.resolveI18nService(environmentId, req); + if (!i18n || typeof i18n.t !== 'function') return undefined; + return (key, locale, params) => i18n.t(key, locale, params); + } + /** * Translate a single metadata document (view or action) when an i18n * service is registered for the request's project and the requested @@ -3968,6 +3993,9 @@ export class RestServer { // runner (also used by the async import-job worker). const summary = await runImport({ p, objectName, environmentId, context, ...prep.prepared, + // #3957 — lets the row report resolve a deployment's + // `validation.field.*` message overrides. + translate: await this.resolveMessageTranslator(environmentId, req), }); res.json({ @@ -4074,6 +4102,11 @@ export class RestServer { ...(createdBy ? { created_by: createdBy } : {}), }; + // Resolved NOW, while the request is still alive: the worker + // below runs after the 201 response, when `req` (and its + // headers) are no longer safe to read (#3957). + const messageTranslator = await this.resolveMessageTranslator(environmentId, req); + try { // [ADR-0103] sys_import_job rows are engine-owned — the import // worker owns their lifecycle, and the object is locked to @@ -4114,6 +4147,7 @@ export class RestServer { try { const summary = await runImport({ p, objectName, environmentId, context, ...prepared, + translate: messageTranslator, captureUndo, progressEvery: 200, onProgress: (pr) => patch({ diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 43e101eda1..fc0d270421 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -21,6 +21,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { resolveAuthzContext, @@ -230,7 +231,14 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise + * f.code === 'required')`) and must stay stable. Do not localize, rename, or + * repurpose an entry; add a new one instead. + * + * The first block is emitted by the field-level validator (declared `Field` + * metadata: `required`, `min`/`max`, `maxLength`, format, options, value + * shape). The second is emitted by the object-level rule evaluator (ADR-0020 + * `ValidationRule`s), where an author-written `message` normally wins. + */ +export const FieldValidationCode = z.enum([ + // ── field-level constraints (Field metadata) ── + 'required', + 'min_length', + 'max_length', + 'min_value', + 'max_value', + 'invalid_email', + 'invalid_url', + 'invalid_phone', + 'invalid_number', + 'invalid_boolean', + 'invalid_date', + 'invalid_time', + 'invalid_option', + 'invalid_type', + // ── object-level validation rules (ADR-0020, rule-validator.ts) ── + 'invalid_transition', + 'invalid_initial_state', + 'rule_violation', + 'invalid_format', + 'invalid_json', + 'json_schema_violation', +]); + +export type FieldValidationCode = z.infer; + +/** + * The violated constraint's discrete values, so a client can format its own + * message instead of parsing `message` (#3957 option 2). + * + * Every key is optional and only the ones the specific `code` implies are + * populated — `min_value` carries `min`, `max_length` carries `maxLength` plus + * the offending `actual` length. Interpolated into the message templates in + * `system/validation-message.ts` under the SAME names, so a translation + * override can reference `{{min}}` / `{{actual}}` directly. + */ +export const FieldValidationParamsSchema = lazySchema(() => z.object({ + /** `min_value` — the declared `Field.min`. */ + min: z.number().optional(), + /** `max_value` — the declared `Field.max`. */ + max: z.number().optional(), + /** `min_length` — the declared `Field.minLength`. */ + minLength: z.number().optional(), + /** `max_length` — the declared `Field.maxLength`. */ + maxLength: z.number().optional(), + /** + * The offending measurement of the supplied value — the string's length for + * the length codes. (The value ITSELF is deliberately not echoed for the + * length codes: it can be a 3000-character blob or hold sensitive input.) + */ + actual: z.number().optional(), + /** The offending value, for codes where it is short and safe to echo (options, states). */ + value: z.union([z.string(), z.number(), z.boolean()]).optional(), + /** Allowed values, comma-joined for display (`options` carries the array form). */ + allowed: z.string().optional(), + /** The field's declared `type`, for the type-shaped codes (`invalid_type`, dates). */ + type: z.string().optional(), + /** Value-shape detail from the spec's derived schema (`invalid_type`, ADR-0104). */ + detail: z.string().optional(), + /** `invalid_transition` — the state being left / entered. */ + from: z.union([z.string(), z.number()]).optional(), + to: z.union([z.string(), z.number()]).optional(), +}).catchall(z.unknown())); + +export type FieldValidationParams = z.infer; + +/** + * One offending field in a `VALIDATION_FAILED` envelope. + * + * `field` is the API name (what the client PATCHed and what a form must + * highlight); `label` is the same field's display name in the caller's locale + * (what a human reads). They are BOTH present because they answer different + * questions — a UI needs the API name to focus the input and the label to write + * the sentence, and collapsing them is exactly how `penalty_amount must be ≥ 0` + * reached end users. + */ +export const FieldValidationErrorSchema = lazySchema(() => z.object({ + field: z.string().describe('API field name (snake_case) — what the client supplied'), + code: FieldValidationCode.describe('Machine identity of the violated constraint'), + message: z.string().describe('Human-readable message, rendered in the caller’s locale'), + label: z.string().optional().describe('Field display label in the caller’s locale'), + params: FieldValidationParamsSchema.optional().describe('Discrete constraint values for client-side formatting'), + options: z.array(z.string()).optional().describe('Allowed values for select/multiselect'), +})); + +export type FieldValidationError = z.infer; diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 985601e81f..09f2792f68 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -80,8 +80,12 @@ export interface ResolveOptions { * * Order: exact → case-insensitive → base-language → variant-expansion. * Returns the matched bundle key, or `undefined` when nothing matches. + * + * Exported so sibling locale-keyed constant tables in this package — the + * built-in validation message catalog (`validation-message.ts`) — match locales + * by the same rules instead of accreting a third copy of this ladder. */ -function resolveBundleLocale( +export function resolveBundleLocale( bundle: Record, requested: string, ): string | undefined { @@ -802,6 +806,40 @@ export interface ResolvedFieldLabel { options?: Record; } +/** + * The caller's preferred locale from an `Accept-Language` header value — the + * highest-priority tag, with its quality weight stripped + * (`zh-CN,zh;q=0.9,en;q=0.8` → `zh-CN`). Returns `undefined` for an absent or + * empty header. + * + * One implementation because two transports need the same answer: REST reads it + * for metadata translation AND for the execution context's locale, while the + * runtime dispatcher reads it for the latter (#3957). A message rendered from a + * different locale than the labels around it is the inconsistency that issue is + * about, so the two must not drift. + */ +export function preferredLocaleFromHeader( + header: string | null | undefined, +): string | undefined { + if (typeof header !== 'string' || header.length === 0) return undefined; + const top = header.split(',')[0]?.split(';')[0]?.trim(); + // `*` is "any language" — no preference expressed. + return top && top !== '*' ? top : undefined; +} + +/** + * Dot-notation i18n key for a field's translated label — + * `objects..fields..label`. + * + * The same location {@link resolveObjectFieldLabels} reads out of a bundle + * object, spelled for consumers that hold an `II18nService` (which takes a key) + * rather than a `TranslationBundle`. Used by the write-path validator to name + * the offending field in the caller's language (#3957). + */ +export function objectFieldLabelKey(objectName: string, fieldName: string): string { + return `objects.${objectName}.fields.${fieldName}.label`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 41e68f6da2..acbf9842be 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -63,6 +63,8 @@ export * from './worker.zod'; export * from './notification.zod'; export * from './translation.zod'; export * from './i18n-resolver'; +// Localized templates for the built-in field-validation messages (#3957). +export * from './validation-message'; export * from './translation-typegen'; export * from './translation-skeleton'; export * from './collaboration.zod'; diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts new file mode 100644 index 0000000000..f8aae19e8f --- /dev/null +++ b/packages/spec/src/system/validation-message.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + BUILTIN_VALIDATION_MESSAGES, + VALIDATION_MESSAGE_FALLBACK_LOCALE, + interpolateValidationMessage, + renderValidationMessage, + validationMessageTranslationKey, +} from './validation-message'; + +/** + * #3957 — the write path's built-in messages were hardcoded English templates + * with the API field name concatenated in, so a `zh-CN` user reading a rejected + * import row got `penalty_amount must be ≥ 0`. These tests pin the catalog's + * contract: every locale is complete, `en` output is unchanged from the + * pre-#3957 strings, and a deployment can override any message. + */ +describe('validation message catalog — completeness', () => { + const en = BUILTIN_VALIDATION_MESSAGES[VALIDATION_MESSAGE_FALLBACK_LOCALE]; + + it('ships the four platform locales', () => { + expect(Object.keys(BUILTIN_VALIDATION_MESSAGES).sort()).toEqual( + ['en', 'es-ES', 'ja-JP', 'zh-CN'], + ); + }); + + /** + * `en` is the last-resort fallback, so a key missing from ANOTHER locale + * degrades to English — but a key missing from `en` degrades to + * `