diff --git a/.changeset/27221-tenant-rewritten-rule-message-wins.md b/.changeset/27221-tenant-rewritten-rule-message-wins.md new file mode 100644 index 00000000000..d417320b711 --- /dev/null +++ b/.changeset/27221-tenant-rewritten-rule-message-wins.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +--- + +fix(i18n): a tenant's rewrite of a packaged validation rule message is served as written in every locale, on the refusal and on `/meta` (#27221) + +Clause-②: yes (widening) +Metadata-protocol: semantic + +A package's bundle translates the rule messages that package shipped (`objects.._validations..message`). When a tenant overlaid a packaged object and reworded one of its validation rule messages, keeping the rule's name, a reader in any locale the bundle carries got the package's translation instead of the tenant's sentence. The rejected write's error and the rule served by `/meta` (and the public form) both showed it. + +- **Now.** Both readers apply the bundle entry only while the served message still equals the packaged rule's message of the same name. A message that differs is the tenant's own and is served and returned as written, in every locale. Rules the tenant left alone are still translated. A `conditional` branch is matched by its own name. A rule the owning package does not declare (one an `objectExtensions` entry adds, or one the tenant added) has no packaged message to differ from and is still translated under its own name, as before. With no packaged owner (an object authored entirely at runtime), nothing changes. +- **One rule, both readers.** `/meta` (`translateObject`) and the rule evaluator use the same comparison, the one already applied to an object's `label`, `description`, embedded list views and fields. `/meta` and the refusal of the same write still show one sentence for one rule and locale. +- **`scalarOverridesPackagedBase`** (`@objectstack/spec/system`) now also accepts the key `'message'`, so the rule evaluator judges a rule message through it. Its behaviour for the existing keys is unchanged. +- **`evaluateValidationRules`'s `messages` option** (`EvaluateRulesOptions.messages`, `@objectstack/objectql`) gains an optional `packagedRule` lookup, which the engine fills from the object's packaged owner. A caller that passes its own `messages` without it keeps the previous behaviour. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index b05d645407e..8001e241935 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -223,6 +223,19 @@ Objects follow the same rule for their `label`, `pluralLabel` and label an [object extension](/docs/data-modeling/object-extensions) contributes, for one) is served as written. +An object's validation rule messages follow it too, on both places a reader +sees them: the error a rejected write returns, and the rule served with the +object (`/meta`, and the forms built from it). The rule's `message` is compared +with the message of the rule of the same `name` in the owning package's +declaration, a `conditional` rule's `then` / `otherwise` branch by its own name. +An organization that overlays a packaged object and rewords a rule's message, +keeping its name, gets that sentence as written in every locale; the rules it +left alone are still translated, and both places always show the same sentence. +Unlike a widget or a bulk action, a rule the owning package does not declare +(one an object extension adds, or one the organization added) is **still +translated** under its own name, `objects.._validations..message`: +there is no shipped message for it to differ from. + ## Organizing the files How you lay out translation source files is an authoring convention — your diff --git a/packages/objectql/src/engine-validation-messages.ts b/packages/objectql/src/engine-validation-messages.ts index 5d9194fa75b..ecc2c8ad7e1 100644 --- a/packages/objectql/src/engine-validation-messages.ts +++ b/packages/objectql/src/engine-validation-messages.ts @@ -33,6 +33,7 @@ * closure, which runs after `validationMessageContext` has returned and sees * whatever service `setI18nService` has installed by then, as it did. * `setI18nService` itself stays in the class: it is the field's setter. + * `registry` (ADR-0029 D9.2a, the packaged rule lookup) goes as a getter too. * * This module imports the engine only as a TYPE, so there is no module cycle * at run time, and nothing re-exports it, so the published surface does not @@ -40,6 +41,7 @@ */ import type { ObjectQL } from './engine.js'; +import type { ValidationMessageContext } from './validation/record-validator.js'; import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system'; /** @@ -52,22 +54,88 @@ import { objectLabelKey, resolveBundleLocale } from '@objectstack/spec/system'; */ export interface ValidationMessageHost { readonly i18nService: ObjectQL['i18nService']; + readonly registry: ObjectQL['registry']; negotiatedMessageLocale: ObjectQL['negotiatedMessageLocale']; } -/** The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. */ +/** + * The body of `ObjectQL#validationMessageContext`; the method's docblock there is its contract. + * + * [ADR-0029 D9.2a] It also carries `packagedRule` (`ValidationMessageContext`), + * the lookup the rule evaluator judges a tenant's rewrite of a rule message by, + * when the object has a packaged owner — see {@link packagedRuleLookup}. + */ export function validationMessageContext( - this: Pick, + this: Pick, 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 } { +): ValidationMessageContext & { objectName: string } { const t = this.i18nService?.t; + const packagedRule = packagedRuleLookup(this.registry, objectName); return { objectName, locale: this.negotiatedMessageLocale(context?.locale), translate: t ? (key, locale, params) => t.call(this.i18nService, key, locale, params) : undefined, + ...(packagedRule !== undefined ? { packagedRule } : {}), + }; +} + +/** + * How deep {@link packagedRuleLookup} follows `conditional` branches — the + * bound `/meta`'s `translateObjectValidations` walks to, so both readers judge + * the same branches. + */ +const MAX_VALIDATION_DEPTH = 10; + +/** + * [ADR-0029 D9.2a] The packaged (code-shipped) rule of a name on `objectName`, + * for the rule evaluator's divergence test. + * + * The declaration is the packaged OWNER's (`getPackagedObjectOwner`, pre-fold), + * the same body `/meta` hands `translateObject` as `packagedBase` through + * `getPackagedObjectBase`, so both readers compare a served message against one + * packaged rule. Rules are keyed by `name`, each `conditional` branch under its + * own name; the first rule of a name wins, as on `/meta`. + * + * `undefined` — nothing inferred, the catalog applies — when no packaged owner + * is registered for the object, or the registry cannot answer (a partial + * double). The index is built on the first lookup, which a write that refuses + * nothing never makes. + */ +function packagedRuleLookup( + registry: ValidationMessageHost['registry'] | undefined, + objectName: string, +): ((ruleName: string) => unknown) | undefined { + if (typeof registry?.getPackagedObjectOwner !== 'function') return undefined; + let definition: unknown; + try { + definition = registry.getPackagedObjectOwner(objectName)?.definition; + } catch { + // A read over in-memory contributors must not turn a 400 into a 500. + return undefined; + } + if (!definition || typeof definition !== 'object') return undefined; + const validations = (definition as { validations?: unknown }).validations; + let byName: Map | undefined; + return (ruleName) => { + if (byName === undefined) { + const index = new Map(); + const visit = (raw: unknown, depth: number): void => { + if (!raw || typeof raw !== 'object' || depth >= MAX_VALIDATION_DEPTH) return; + const rule = raw as { type?: unknown; name?: unknown; then?: unknown; otherwise?: unknown }; + if (rule.type === 'conditional') { + visit(rule.then, depth + 1); + visit(rule.otherwise, depth + 1); + return; + } + if (typeof rule.name === 'string' && rule.name.length > 0 && !index.has(rule.name)) index.set(rule.name, rule); + }; + if (Array.isArray(validations)) for (const raw of validations) visit(raw, 0); + byName = index; + } + return byName.get(ruleName); }; } diff --git a/packages/objectql/src/engine-validation-tenant-rewrite.test.ts b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts new file mode 100644 index 00000000000..e57f9cdf54f --- /dev/null +++ b/packages/objectql/src/engine-validation-tenant-rewrite.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0029 D9.2a — a tenant's rewrite of a packaged rule message beats the + * packaged translation, on BOTH readers of the rule, and the two still agree. + * + * A packaged object declares two rules and ships their `zh-CN` translations at + * `objects.._validations..message`. A tenant overlays the object + * (ADR-0029 D9) and rewrites one of the two messages, keeping the rule's name. + * The catalog translates the PACKAGED rule, so under `zh-CN`: + * + * - the rewritten rule must answer the tenant's own sentence, as written; + * - the rule the tenant left alone must still answer its translation; + * - a rule a second package folds on through `objectExtensions` — absent + * from the owner's declaration by construction — must still answer its own + * package's translation: it has no packaged message to diverge from. + * + * Both readers are driven off ONE real registry: the refusal of a real write + * (the engine's validator), and the served object as `/meta` translates it — + * `translateObject` over the resolved object, with the packaged base the + * serving layer hands it (`getPackagedObjectBase`, the accessor `/meta` reads + * through `packagedObjectBaseOf`). For every header they must be one string. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { preferredLocaleFromHeader, translateObject } from '@objectstack/spec/system'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './index.js'; + +const OBJ = 'tr_duty'; +const PKG = 'com.objectstack.test.tenant-rewrite'; +const EXT_PKG = 'com.objectstack.test.tenant-rewrite-extension'; + +const PACKAGED = { + returned_needs_note: 'Say why the duty is being returned.', + done_needs_owner: 'A finished duty needs an owner.', + ext_blocked_needs_reason: 'Say what is blocking the duty.', +}; +const ZH = { + returned_needs_note: '请写明打回的原因。', + done_needs_owner: '已完成的职责需要负责人。', + ext_blocked_needs_reason: '请写明阻塞原因。', +}; + +/** The extension package's own rule, folded onto the object. */ +const EXTENSION = { + name: OBJ, + validations: [{ + type: 'script', + name: 'ext_blocked_needs_reason', + message: PACKAGED.ext_blocked_needs_reason, + condition: "record.status == 'blocked' && record.return_note == null", + fields: ['return_note'], + }], +}; +const REWRITE = 'Tell the owner what to change before you send it back.'; + +const rules = (returnedMessage: string) => [ + { + type: 'script', + name: 'returned_needs_note', + message: returnedMessage, + condition: "record.status == 'returned' && record.return_note == null", + fields: ['return_note'], + }, + { + type: 'script', + name: 'done_needs_owner', + message: PACKAGED.done_needs_owner, + condition: "record.status == 'done' && record.owner == null", + fields: ['owner'], + }, +]; + +const body = (returnedMessage: string) => ({ + name: OBJ, + label: 'Duty', + fields: { + status: { type: 'text', label: 'Status' }, + return_note: { type: 'text', label: 'Return note' }, + owner: { type: 'text', label: 'Owner' }, + }, + validations: rules(returnedMessage), +}); + +const BUNDLE: Record = { + en: { + objects: { + [OBJ]: { + _validations: { + returned_needs_note: { message: PACKAGED.returned_needs_note }, + done_needs_owner: { message: PACKAGED.done_needs_owner }, + ext_blocked_needs_reason: { message: PACKAGED.ext_blocked_needs_reason }, + }, + }, + }, + }, + 'zh-CN': { + objects: { + [OBJ]: { + _validations: { + returned_needs_note: { message: ZH.returned_needs_note }, + done_needs_owner: { message: ZH.done_needs_owner }, + ext_blocked_needs_reason: { message: ZH.ext_blocked_needs_reason }, + }, + }, + }, + }, +}; + +/** `FileI18nAdapter.t()`-shaped: the locale exactly, then the fallback, then the key echoed. */ +function servedI18n(bundle: Record, fallbackLocale = 'en') { + const dig = (data: unknown, key: string): unknown => + key.split('.').reduce( + (cur, part) => (cur && typeof cur === 'object' ? (cur as any)[part] : undefined), + data, + ); + return { + t(key: string, locale: string): string { + const exact = dig(bundle[locale], key); + if (typeof exact === 'string') return exact; + const fallback = dig(bundle[fallbackLocale], key); + return typeof fallback === 'string' ? fallback : key; + }, + getLocales: () => Object.keys(bundle), + }; +} + +function makeDriver() { + const rows = new Map(); + return { + name: 'com.objectstack.driver.memory.tr', + version: '1.0.0', + async connect() { /* noop */ }, + async disconnect() { /* noop */ }, + async initObjects() { /* noop */ }, + async find() { return [...rows.values()]; }, + async findOne(_o: string, options?: any) { return rows.get(String(options?.where?.id)) ?? null; }, + async count() { return rows.size; }, + async create(_o: string, data: any) { rows.set(String(data.id), data); return data; }, + async update(_o: string, data: any) { return data; }, + async delete() { return false; }, + } as any; +} + +describe('ADR-0029 D9.2a a tenant-rewritten rule message, on the refusal and on /meta', () => { + let engine: ObjectQL; + let protocol: any; + + /** Boot the packaged object, then (optionally) the tenant's overlay layer. */ + async function boot(overlay: boolean) { + engine = new ObjectQL(); + engine.registerDriver(makeDriver(), true); + await engine.init(); + engine.setI18nService(servedI18n(BUNDLE) as any); + engine.registry.registerObject(body(PACKAGED.returned_needs_note) as any, PKG); + engine.registry.registerObject(EXTENSION as any, EXT_PKG, undefined, 'extend'); + if (overlay) { + engine.registry.registerObject({ ...body(REWRITE), _provenance: 'org' } as any, PKG, undefined, 'overlay'); + } + protocol = new ObjectStackProtocolImplementation(engine as any); + } + + afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + async function refusal(header: string, data: Record): Promise { + try { + await engine.insert(OBJ, data, { context: { locale: preferredLocaleFromHeader(header) } } as any); + } catch (e: any) { + return String(e?.fields?.[0]?.message ?? e?.message); + } + throw new Error('expected the write to be rejected'); + } + + function served(header: string): Record { + const doc = translateObject(engine.registry.getObject(OBJ) as any, BUNDLE as any, { + locale: preferredLocaleFromHeader(header), + fallbackChain: ['en'], + packagedBase: protocol.getPackagedObjectBase(OBJ), + }) as any; + return Object.fromEntries(doc.validations.map((r: any) => [r.name, r.message])); + } + + async function table() { + const out: Record = {}; + for (const header of ['zh-CN', 'zh', 'en']) { + out[header] = { + served: served(header), + refusal: { + returned_needs_note: await refusal(header, { id: 'r1', status: 'returned' }), + done_needs_owner: await refusal(header, { id: 'r2', status: 'done' }), + ext_blocked_needs_reason: await refusal(header, { id: 'r3', status: 'blocked' }), + }, + }; + } + return out; + } + + describe('with the tenant overlay', () => { + beforeEach(async () => { await boot(true); }); + + it('the overlay is what both readers see, over a packaged owner that is still there', () => { + const served = (engine.registry.getObject(OBJ) as any).validations.map((r: any) => r.name); + expect(served).toEqual(['returned_needs_note', 'done_needs_owner', 'ext_blocked_needs_reason']); + expect((engine.registry.getObject(OBJ) as any).validations[0].message).toBe(REWRITE); + const packaged = protocol.getPackagedObjectBase(OBJ) as any; + expect(packaged.validations.map((r: any) => r.name)).toEqual(['returned_needs_note', 'done_needs_owner']); + expect(packaged.validations[0].message).toBe(PACKAGED.returned_needs_note); + }); + + it('serves and refuses with the rewrite as written, and still translates the untouched and extension rules', async () => { + const rows = (t: typeof ZH) => ({ + returned_needs_note: REWRITE, + done_needs_owner: t.done_needs_owner, + ext_blocked_needs_reason: t.ext_blocked_needs_reason, + }); + expect(await table()).toEqual({ + 'zh-CN': { served: rows(ZH), refusal: rows(ZH) }, + zh: { served: rows(ZH), refusal: rows(ZH) }, + en: { served: rows(PACKAGED), refusal: rows(PACKAGED) }, + }); + }); + }); + + describe('control: no overlay', () => { + beforeEach(async () => { await boot(false); }); + + it('translates every rule on both readers', async () => { + expect(await table()).toEqual({ + 'zh-CN': { served: ZH, refusal: ZH }, + zh: { served: ZH, refusal: ZH }, + en: { served: PACKAGED, refusal: PACKAGED }, + }); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2b9a80b31b7..ff193bf862f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -335,7 +335,7 @@ import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js'; import { declaredHookCondition } from './hook-wrappers.js'; import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, isScannableValueShapeField } from './validation/record-validator.js'; -import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; +import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink, ValidationMessageContext } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; import { collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -9542,11 +9542,13 @@ export class ObjectQL implements IObjectQLEngine { // 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 } { + ): ValidationMessageContext & { objectName: string } { // [#25522 cut 1] The body moved to `engine-validation-messages.ts` (see `ValidationMessageHost`). const readI18nService = () => this.i18nService; + const readRegistry = () => this._registry; const host: ThisParameterType = { get i18nService() { return readI18nService(); }, + get registry() { return readRegistry(); }, negotiatedMessageLocale: (...a) => this.negotiatedMessageLocale(...a), }; return validationMessageContext.call(host, objectName, context); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index f47f64b9f0e..0ad57b55921 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -404,6 +404,21 @@ export interface ValidationMessageContext { translate?: ValidationMessageTranslator; /** Object name — needed to address the field's label in the bundle. */ objectName?: string; + /** + * [ADR-0029 D9.2a] The PACKAGED (code-shipped) declaration of the rule of + * this name on `objectName` — a `conditional` branch addressed by its own + * name — read from the packaged owner's declaration, the base `/meta` hands + * its translator as `packagedBase`. An authored `rule.message` that diverged + * from it is a tenant's rewrite, and the catalog (the packaged rule's + * translation) is not applied to it. + * + * Absent when no packaged owner is registered for the object (a + * runtime-authored object, a registry-less host), and `undefined` for a rule + * the packaged declaration does not carry (one an `objectExtensions` entry + * folds on, translated under its own name): either way there is nothing to + * diverge from, and the catalog applies, as on `/meta`. + */ + packagedRule?: (ruleName: string) => unknown; } /** diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 85f72e8b407..2292a9a3c48 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -2649,3 +2649,83 @@ describe('evaluateValidationRules — authored rule messages resolve from the bu expect(err.message).not.toBe('这条不该出现。'); }); }); + +/** + * ADR-0029 D9.2a — a tenant's rewrite of a rule message beats the packaged + * translation in the refusal. + * + * The catalog entry at `objects.._validations..message` translates + * the PACKAGED rule. When the served rule's message diverged from the packaged + * rule of the same name, the sentence is the tenant's own and is returned as + * written, judged by `scalarOverridesPackagedBase` (the one comparison `/meta` + * judges the same message by). `messages.packagedRule` is the engine's lookup + * into the packaged owner's declaration; absent, or answering no rule of the + * name, there is nothing to diverge from and the catalog applies as before. + */ +describe('evaluateValidationRules — a tenant-rewritten rule message beats the packaged translation (ADR-0029 D9.2a)', () => { + const PACKAGED = 'A standing duty must declare a frequency.'; + const REWRITE = 'Pick how often this duty repeats.'; + const ZH = '常规任务必须设置频率。'; + const KEY = 'zh-CN|objects.duly_duty._validations.standing_no_frequency.message'; + const t = (key: string, locale: string) => ({ [KEY]: ZH } as Record)[`${locale}|${key}`] ?? key; + + const failing = (schema: any, data: Record, opts: Record) => { + try { + evaluateValidationRules(schema, data, 'insert', opts); + } catch (e) { + return (e as ValidationError).fields; + } + throw new Error('expected a ValidationError'); + }; + + const scriptRule = (message: string) => ({ + type: 'script', + name: 'standing_no_frequency', + message, + condition: "record.frequency == null || record.frequency == ''", + fields: ['frequency'], + }); + const schemaWith = (message: string) => ({ + fields: { frequency: { type: 'select', label: '频率' } }, + validations: [scriptRule(message)], + }); + const packagedRule = (name: string) => (name === 'standing_no_frequency' ? scriptRule(PACKAGED) : undefined); + const messages = (extra: Record = {}) => + ({ locale: 'zh-CN', objectName: 'duly_duty', translate: t, ...extra }); + + it('returns the rewrite as written in a non-default locale', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages({ packagedRule }) }); + expect(err.message).toBe(REWRITE); + expect(err.code).toBe('rule_violation'); + expect(err.field).toBe('frequency'); + }); + + it('still translates a rule whose message is the packaged one', () => { + const [err] = failing(schemaWith(PACKAGED), {}, { messages: messages({ packagedRule }) }); + expect(err.message).toBe(ZH); + }); + + it('translates a rule the packaged object does not declare (an extension rule), under its own name', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages({ packagedRule: () => undefined }) }); + expect(err.message).toBe(ZH); + }); + + it('infers nothing without a packaged base: the catalog applies, as before', () => { + const [err] = failing(schemaWith(REWRITE), {}, { messages: messages() }); + expect(err.message).toBe(ZH); + }); + + it('judges a conditional branch against the packaged branch of the same name', () => { + const conditional = (branchMessage: string) => ({ + type: 'conditional', + name: 'standing_gate', + message: 'wrapper', + when: 'true', + then: { ...scriptRule(branchMessage) }, + }); + const packaged = (name: string) => (name === 'standing_no_frequency' ? scriptRule(PACKAGED) : undefined); + const schema = (m: string) => ({ fields: { frequency: { type: 'select' } }, validations: [conditional(m)] }); + expect(failing(schema(REWRITE), {}, { messages: messages({ packagedRule: packaged }) })[0].message).toBe(REWRITE); + expect(failing(schema(PACKAGED), {}, { messages: messages({ packagedRule: packaged }) })[0].message).toBe(ZH); + }); +}); diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 51aa0041d03..1c022209dbe 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -243,7 +243,7 @@ import { recordAdvisoryHit } from '@objectstack/core'; // reports it unconditionally, even under `systemFields: false`), and the // engine's whole by-id addressing reads that key (`idAddressesThisRow`, // `addressKey: 'id'`). -import { SystemFieldName, objectValidationMessageKey } from '@objectstack/spec/system'; +import { SystemFieldName, objectValidationMessageKey, scalarOverridesPackagedBase } from '@objectstack/spec/system'; import Ajv, { type ValidateFunction } from 'ajv'; // #5029 — `format` is NOT built into ajv 8; it ships in this separate package. // See the `const ajv` note below for why the runtime registers it. @@ -3759,6 +3759,17 @@ export function emitValidationAdvisories( * * `rule.name` is the address — including a nested `conditional` branch's own * name, since the branch is the rule whose message the caller sees. + * + * ## The catalog loses to a tenant's rewrite — ADR-0029 D9.2a + * + * The catalog entry translates the PACKAGED rule. A message that diverged from + * the packaged rule of the same name (`messages.packagedRule`) is a tenant's + * rewrite and is returned as written, in every locale. The test is + * `scalarOverridesPackagedBase`, the one comparison `/meta`'s + * `translateObjectValidations` judges the same message by (⛔ never a second + * one here), so the served rule and the refusal of the same write keep + * showing one sentence. A rule the packaged declaration does not carry, or no + * lookup at all, has nothing to diverge from: the catalog applies. */ function authoredRuleMessage( rule: Pick, @@ -3766,6 +3777,9 @@ function authoredRuleMessage( ): string { if (messages?.translate && messages.objectName && messages.locale && typeof rule.name === 'string' && rule.name.length > 0) { + if (scalarOverridesPackagedBase(messages.packagedRule?.(rule.name), 'message', rule.message)) { + return rule.message; + } const key = objectValidationMessageKey(messages.objectName, rule.name); try { const translated = messages.translate(key, messages.locale); diff --git a/packages/spec/src/system/i18n-resolver.object-validations.test.ts b/packages/spec/src/system/i18n-resolver.object-validations.test.ts index 876897db7da..4c0762fbdb1 100644 --- a/packages/spec/src/system/i18n-resolver.object-validations.test.ts +++ b/packages/spec/src/system/i18n-resolver.object-validations.test.ts @@ -253,3 +253,80 @@ describe('#27135 the fields and actions overlays are unchanged', () => { expect('validations' in out).toBe(false); }); }); + +/** + * ADR-0029 D9.2a — the catalog LOSES to a tenant's rewrite of a rule message. + * + * The catalog translates the PACKAGED rule, so a served message that diverged + * from the packaged rule of the same name is the tenant's own sentence and is + * served as written, in every locale. The comparison is the one every other + * string on the object is judged by (`valueOverridesPackagedBase`), against + * the packaged object the serving layer hands over as `packagedBase`. The + * refusal of the same write applies the same rule beside the evaluator, in + * objectql's `engine-validation-tenant-rewrite`. + */ +describe('ADR-0029 D9.2a a tenant-rewritten rule message beats the packaged translation', () => { + const REWRITE = { + closed_is_final: 'Closed tickets stay closed — open a new one instead.', + vip_branch: 'Every VIP ticket needs a named owner.', + }; + + /** + * The tenant's overlay: two messages rewritten, and one rule the packaged + * object does not declare (as an `objectExtensions` rule is folded on). + */ + const tenantDoc = () => { + const doc = clone(DOC); + doc.validations[0].message = REWRITE.closed_is_final; + doc.validations[3].then.message = REWRITE.vip_branch; + doc.validations.push(rule({ + type: 'script', + name: 'unpackaged_rule', + message: 'A rule the package does not declare.', + condition: 'record.owner == null', + })); + return doc; + }; + + /** The catalog carries an entry for the unpackaged rule under its own name. */ + const bundle = TranslationBundleSchema.parse({ + 'zh-CN': localeData({ ...ZH, unpackaged_rule: '套件未声明的规则。' }), + }); + + it('serves the rewrite as written, and still translates every rule the tenant left alone', () => { + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN', packagedBase: clone(DOC) }) as any; + expect(messagesOf(out)).toEqual({ + closed_is_final: REWRITE.closed_is_final, + refund_needs_reason: ZH.refund_needs_reason, + untranslated_rule: AUTHORED.untranslated_rule, + wrapper: AUTHORED.wrapper, + vip_branch: REWRITE.vip_branch, + standard_branch: ZH.standard_branch, + // Nothing to diverge from: its own entry still applies. + unpackaged_rule: '套件未声明的规则。', + }); + }); + + it('applies the catalog to every rule when the packaged object declares none', () => { + const { validations: _dropped, ...bare } = clone(DOC); + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN', packagedBase: bare }) as any; + expect(out.validations[0].message).toBe(ZH.closed_is_final); + expect(out.validations[3].then.message).toBe(ZH.vip_branch); + expect(out.validations[4].message).toBe('套件未声明的规则。'); + }); + + it('infers nothing without a packaged base: the catalog applies, as before', () => { + const out = translateObject(tenantDoc(), bundle, { locale: 'zh-CN' }) as any; + expect(out.validations[0].message).toBe(ZH.closed_is_final); + expect(out.validations[3].then.message).toBe(ZH.vip_branch); + }); + + it('is reached through the /meta door with the base the serving layer supplies', () => { + const out = translateMetadataDocument('object', tenantDoc(), bundle, { + locale: 'zh-CN', + packagedBase: clone(DOC), + }) as any; + expect(out.validations[0].message).toBe(REWRITE.closed_is_final); + expect(out.validations[1].message).toBe(ZH.refund_needs_reason); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 06ccb90986b..0d0d431f933 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3852,10 +3852,21 @@ function builtinSystemFieldLabel( * * ⛔ The three conservative edges above are therefore load-bearing in two * places. Widening any of them widens the fold's precedence rule too. + * + * ## `message` — a validation rule's, judged on both of its readers + * + * A rule's `message` is served on `/meta` ({@link translateObjectValidations}) + * and returned in the refusal of a write by `@objectstack/objectql`'s rule + * evaluator, and both apply the catalog only while the served message still + * equals the packaged rule's of the same name (ADR-0029 D9.2a). The evaluator + * asks through this export, with the packaged RULE as `base`, so a tenant's + * rewrite is judged by one comparison on both readers and the two keep + * answering one sentence. The key is the only thing it adds: the three edges + * above hold for it unchanged. */ export function scalarOverridesPackagedBase( base: unknown, - key: 'label' | 'pluralLabel' | 'description', + key: 'label' | 'pluralLabel' | 'description' | 'message', value: unknown, ): boolean { return valueOverridesPackagedBase(base, key, value); @@ -3985,21 +3996,36 @@ const MAX_VALIDATION_DEPTH = 10; * branches are translated and the wrapper's `message` is left as authored, the * way the extractor leaves it unscaffolded. * - * ## No packaged-base comparison - * - * Unlike the object's scalars, a rule message is a flat `catalog ?? document`, - * as a field `label` is. The evaluator translates a refusal without consulting - * any packaged base, and a divergence rule applied here alone would serve one - * sentence on `/meta` and another in the refusal of the same write, which is - * the split this overlay exists to close. + * ## The catalog loses to a tenant's rewrite — ADR-0029 D9.2a + * + * The catalog translates the PACKAGED rule, so a served message that diverged + * from the packaged rule of the same name — a tenant overlay that kept the + * rule and reworded it — is authored data and keeps its text, judged by + * {@link valueOverridesPackagedBase} (⛔ never a second comparison) against + * the rule in {@link TranslateDocumentOptions.packagedBase}'s `validations`, + * a `conditional` branch by its own name. + * + * A rule the base does not declare has no packaged message to diverge from, + * so nothing is inferred and the catalog applies, as it does with no base at + * all (the predicate's own first edge). Unlike a list view, a rule is + * translated under its OWN name: a rule an `objectExtensions` entry folds onto + * the object, absent from the owner's declaration by construction, is + * translated by its package's entry for that name, and counting it as + * diverged would serve it untranslated. + * + * The evaluator applies the same rule to its refusal, through the exported + * {@link scalarOverridesPackagedBase} and the packaged owner's declaration the + * engine hands it, so `/meta` and the refusal of the same write still show one + * sentence for one rule and locale. */ function translateObjectValidations( validations: unknown, bundle: TranslationBundle | undefined, objectName: string, - opts: ResolveOptions | undefined, + opts: TranslateDocumentOptions | undefined, ): unknown { if (!Array.isArray(validations) || !bundle) return validations; + const packaged = packagedRulesByName(opts?.packagedBase); const translateRule = (raw: unknown, depth: number): unknown => { const rule = asRecord(raw); if (rule === undefined || depth >= MAX_VALIDATION_DEPTH) return raw; @@ -4014,6 +4040,7 @@ function translateObjectValidations( }; } if (typeof rule.name !== 'string' || rule.name.length === 0) return raw; + if (valueOverridesPackagedBase(packaged.get(rule.name), 'message', rule.message)) return raw; const path = objectValidationMessageKey(objectName, rule.name).split('.'); for (const code of localeChain(opts)) { const candidate = path.reduce( @@ -4035,6 +4062,30 @@ function translateObjectValidations( return changed ? next : validations; } +/** + * The packaged object's validation rules by `name`, each `conditional` branch + * under its own name to {@link MAX_VALIDATION_DEPTH}, for + * {@link translateObjectValidations}' divergence test — empty when no base is + * known or it declares no rules. The first rule of a name wins. + */ +function packagedRulesByName(packagedBase: unknown): Map> { + const base = asRecord(packagedBase); + const byName = new Map>(); + const visit = (raw: unknown, depth: number): void => { + const rule = asRecord(raw); + if (rule === undefined || depth >= MAX_VALIDATION_DEPTH) return; + if (rule.type === 'conditional') { + visit(rule.then, depth + 1); + visit(rule.otherwise, depth + 1); + return; + } + if (typeof rule.name === 'string' && rule.name.length > 0 && !byName.has(rule.name)) byName.set(rule.name, rule); + }; + const rules = base?.validations; + if (Array.isArray(rules)) for (const raw of rules) visit(raw, 0); + return byName; +} + /** * Apply the active locale to an object metadata document. Translates the * object's `label` / `pluralLabel` / `description`, walks each field to @@ -4091,6 +4142,8 @@ function translateObjectValidations( * application of the same rule (ADR-0029 D9.2a, as {@link translateView} * applies it), judged per view against the packaged object's own `listViews` — * see {@link translateObjectListViews}. + * Each `validations[].message` follows it too, judged against the packaged + * rule of the same name — see {@link translateObjectValidations}. * * ## A field's translated help is served on `description` — ADR-0029 D9.2a *