Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21948-spec-field-help-served-on-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

A served object field no longer carries an undeclared `help` key. `translateObject` now serves a field's translated help on the field's `description` (#21948).

Clause-②: no

- **What moved.** A bundle's `objects.<object>.fields.<field>.help` entry is the translation of the field's `description`, because the i18n extractor writes it from that key. `translateObject` (and so `GET /api/v1/meta/object/:name` in a non-English locale) used to put it on a `help` key that `FieldSchema` does not declare. The served field then failed `FieldSchema` with `unrecognized_keys`. A consumer that reads only declared keys rendered the English `description`, and the console logged one ingestion warning per such field. The translation is now served on `description`, and the served field carries no `help`.
- **Precedence (ADR-0029 D9.2a).** The catalog applies only while the served field's `description` still equals the packaged field's. This is judged by the same comparison the object scalars, views and dashboards use. A description that diverged (an `objectExtensions` field, or a tenant's own edit) keeps its authored value in every locale. With no packaged base supplied, the catalog applies, as before. A field's `label` is unchanged and stays a flat `catalog ?? document`.
- **`ObjectFieldLike`** (`@objectstack/spec/system`) drops its `help?: string` member. Its `[key: string]: any` index signature still accepts and types a `help` key, so a caller that writes or reads one still compiles. `inlineHelpText` is not touched.
- Readers that fall back from `help` to `description` (`field.help || field.description`) render the same translated text as before.
- ⛔ No schema, parse or export change. The translation bundle's own field `help` key is unchanged.
169 changes: 169 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3548,6 +3548,175 @@ describe('translateObject — catalog vs explicit override (#8284)', () => {
});
});

// ────────────────────────────────────────────────────────────────────────────
// translateObject — a field's translated help is served on `description` (#21948)
// ────────────────────────────────────────────────────────────────────────────

import { FieldSchema } from '../data/field.zod';

/**
* #21948 — the bundle's `objects.<object>.fields.<field>.help` entry is the
* translation of the field's `description` (the i18n extractor writes it from
* that key), and it used to be served on a `help` key `FieldSchema` does not
* declare. Every consumer that reads only declared keys rendered the English
* `description`, and the console warned once per such field on every load.
*
* The entry is served on `description` now, under ADR-0029 D9.2a: the catalog
* applies only while the served `description` still equals the packaged
* field's (`valueOverridesPackagedBase`), and with no packaged base it applies.
*/
describe('translateObject — a field\'s translated help is served on `description`', () => {
const TWO_FACTOR_EN =
'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.';
const TWO_FACTOR_ZH = '该用户是否已启用双因素认证。由 better-auth 的 `twoFactor` 插件维护。';

/** The packaged declaration, as the owner contributor holds it. */
const PACKAGED = {
name: 'sys_user',
label: 'User',
fields: {
two_factor_enabled: {
name: 'two_factor_enabled',
type: 'boolean',
label: 'Two-Factor Enabled',
description: TWO_FACTOR_EN,
},
phone: {
name: 'phone',
type: 'text',
label: 'Phone',
description: 'Contact number.',
inlineHelpText: 'Include the country code.',
},
nickname: { name: 'nickname', type: 'text', label: 'Nickname' },
},
};

/**
* What the extractor writes: `en` repeats the packaged `description` under
* `help`, `zh-CN` is its translation. `nickname` has no entry at all.
*/
const BUNDLE: TranslationBundle = {
en: {
objects: {
sys_user: {
fields: {
two_factor_enabled: { label: 'Two-Factor Enabled', help: TWO_FACTOR_EN },
phone: { label: 'Phone', help: 'Contact number.' },
},
},
},
} as any,
'zh-CN': {
objects: {
sys_user: {
fields: {
two_factor_enabled: { label: '已启用双因素认证', help: TWO_FACTOR_ZH },
phone: { label: '电话', help: '联系电话。' },
},
},
},
} as any,
};

const clone = <V>(value: V): V => JSON.parse(JSON.stringify(value));
const fieldsOf = (out: unknown) => (out as { fields: Record<string, Record<string, unknown>> }).fields;
const unrecognizedKeys = (def: unknown) => {
const parsed = FieldSchema.safeParse(def);
return parsed.success ? [] : parsed.error.issues.filter((issue) => issue.code === 'unrecognized_keys');
};

it('serves the zh-CN translation on `description`, carries no `help` key, and parses with no unrecognized key', () => {
const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }))
.two_factor_enabled;
expect(field.description).toBe(TWO_FACTOR_ZH);
expect('help' in field).toBe(false);
expect(field.label).toBe('已启用双因素认证');
expect(unrecognizedKeys(field)).toEqual([]);
});

it('leaves a field with no bundle entry exactly as it came — the control', () => {
const doc = clone(PACKAGED);
const field = fieldsOf(translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).nickname;
expect(field).toEqual(PACKAGED.fields.nickname);
expect('description' in field).toBe(false);
expect('help' in field).toBe(false);
});

it('a field declaring both `description` and `inlineHelpText` gets the translation on `description`; `inlineHelpText` is untouched', () => {
const field = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).phone;
expect(field.description).toBe('联系电话。');
expect(field.inlineHelpText).toBe('Include the country code.');
expect('help' in field).toBe(false);
expect(unrecognizedKeys(field)).toEqual([]);
});

it('keeps a `description` that diverged from the packaged field, in every locale, through the type dispatch', () => {
// The REST boundary reaches `translateObject` through the dispatcher, so the
// packaged base has to arrive there for the comparison to be made at all.
const served = clone(PACKAGED);
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
for (const locale of ['zh-CN', 'en']) {
const fields = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale, packagedBase: PACKAGED }));
expect(fields.two_factor_enabled.description).toBe('Edited by the tenant.');
expect('help' in fields.two_factor_enabled).toBe(false);
}
// Judged per field: the undiverged sibling is still translated…
const zh = fieldsOf(translateMetadataDocument('object', clone(served), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
expect(zh.phone.description).toBe('联系电话。');
// …and the field `label` stays a flat `catalog ?? document`, as before.
expect(zh.two_factor_enabled.label).toBe('已启用双因素认证');
});

it('a field the packaged base does not declare counts as diverged', () => {
// An `objectExtensions` field is folded on after the owner's declaration,
// which is all the base carries.
const served = clone(PACKAGED) as any;
served.fields.extra = { name: 'extra', type: 'text', label: 'Extra', description: 'Added by an extension.' };
const bundle = clone(BUNDLE) as any;
bundle['zh-CN'].objects.sys_user.fields.extra = { help: '扩展添加。' };
const field = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: PACKAGED })).extra;
expect(field.description).toBe('Added by an extension.');
expect('help' in field).toBe(false);
});

it('with no packaged base, the catalog applies — "unknown" is not "authored"', () => {
const served = clone(PACKAGED);
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
for (const packagedBase of [undefined, null]) {
const field = fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN', packagedBase })).two_factor_enabled;
expect(field.description).toBe(TWO_FACTOR_ZH);
expect('help' in field).toBe(false);
}
expect(fieldsOf(translateObject(clone(served), BUNDLE, { locale: 'zh-CN' })).two_factor_enabled.description)
.toBe(TWO_FACTOR_ZH);
});

it('an absent served `description` is not an override — the catalog fills it', () => {
const served = clone(PACKAGED) as any;
delete served.fields.two_factor_enabled.description;
const field = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED })).two_factor_enabled;
expect(field.description).toBe(TWO_FACTOR_ZH);
});

it('judges an array-shaped field list against an array-shaped base by field name', () => {
const asArray = (doc: typeof PACKAGED) => ({ ...doc, fields: Object.values(doc.fields) });
const served = clone(PACKAGED);
served.fields.two_factor_enabled.description = 'Edited by the tenant.';
const out = translateObject(asArray(served) as any, BUNDLE, { locale: 'zh-CN', packagedBase: asArray(PACKAGED) });
const byName = Object.fromEntries((out.fields as any[]).map((f) => [f.name, f]));
expect(byName.two_factor_enabled.description).toBe('Edited by the tenant.');
expect(byName.phone.description).toBe('联系电话。');
expect((out.fields as any[]).some((f) => 'help' in f)).toBe(false);
});

it('does not mutate the input document', () => {
const doc = clone(PACKAGED);
translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
expect(doc).toEqual(PACKAGED);
});
});

// ════════════════════════════════════════════════════════════════════════════
// Screen-flow copy resolvers (#7646 / #11287)
// ════════════════════════════════════════════════════════════════════════════
Expand Down
86 changes: 75 additions & 11 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,36 @@ function packagedPart(
return {};
}

/**
* The packaged counterpart of one served FIELD of an object, with the same
* three answers as {@link packagedPart}: `undefined` when no packaged base was
* supplied, `{}` when the base is known but declares no such field (a field
* authored after the fact — an `objectExtensions` field is folded on after the
* owner's declaration the base carries), and the packaged field otherwise.
*
* A separate finder only because a field map is a record keyed by field name
* (the canonical shape) where {@link packagedPart} walks an array; the array
* shape some reads flatten to is matched by `name`, as `translateObject`
* itself accepts both.
*/
function packagedObjectField(
packagedObject: Record<string, unknown> | undefined,
fieldName: string,
): Record<string, unknown> | undefined {
if (packagedObject === undefined) return undefined;
const fields = packagedObject.fields;
if (Array.isArray(fields)) {
for (const candidate of fields) {
const record = asRecord(candidate);
if (record !== undefined && record.name === fieldName) return record;
}
return {};
}
const map = asRecord(fields);
if (map === undefined || !Object.prototype.hasOwnProperty.call(map, fieldName)) return {};
return asRecord(map[fieldName]) ?? {};
}

/**
* Overlay `dashboards.<name>.globalFilters.<key>.{label,options.<value>}`
* onto one authored filter (#16772). Returns the input object itself when
Expand Down Expand Up @@ -2414,10 +2444,15 @@ export interface ObjectLike {
listViews?: Record<string, unknown>;
}

/**
* Minimal field shape consumed by `translateObject`. It names only keys
* `FieldSchema` declares: there is no `help` here, because a field spells its
* help text `description` / `inlineHelpText`. The bundle's field `help` entry
* is served on `description` (see {@link translateObject}).
*/
export interface ObjectFieldLike {
name?: string;
label?: string;
help?: string;
description?: string;
options?: Array<{ label?: string; value: string | number | boolean }>;
[key: string]: any;
Expand Down Expand Up @@ -2906,7 +2941,8 @@ function translateObjectListViews(
/**
* Apply the active locale to an object metadata document. Translates the
* object's `label` / `pluralLabel` / `description`, walks each field to
* translate its `label`, `help`, and per-option `label`s, walks any
* translate its `label`, its `description` (from the bundle's field `help`
* entry — see below), and per-option `label`s, walks any
* inline-declared `actions` through {@link translateAction}, and overlays the
* copy of each EMBEDDED `listViews` entry from the `_views` keys the i18n
* extractor writes for it ({@link translateObjectListViews}). The input document
Expand Down Expand Up @@ -2949,13 +2985,35 @@ function translateObjectListViews(
* conservative edges. `?layers=true` stays untranslated and diagnostic,
* unchanged.
*
* The rule is scoped to the three SCALARS, which are what the ruling covers:
* `fields` is a key-keyed spread whose per-field labels have their own
* (per-field) catalog keys, and no read has been measured to diverge on them.
* The embedded `listViews` follow the VIEW translator's 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}.
* The ruling covers the three SCALARS. A field's `label` stays a flat
* `catalog ?? document`: `fields` is a key-keyed spread whose per-field labels
* have their own (per-field) catalog keys, and no read has been measured to
* diverge on them. The embedded `listViews` follow the VIEW translator's
* 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}.
*
* ## A field's translated help is served on `description` — ADR-0029 D9.2a
*
* The bundle addresses a field's help as `objects.<object>.fields.<field>.help`
* (`FieldTranslationSchema.help`), and the i18n extractor writes that entry
* from the field's `description` (`packages/cli/src/utils/i18n-extract.ts`;
* its other source, a field `help`, is a key `FieldSchema` refuses). So the
* entry is the translation of `description`, and it is served THERE. It is
* ⛔ never served on a `help` key, which `FieldSchema` does not declare: the
* served field def then carried a key the contract refuses, every consumer
* that reads only declared keys rendered the source-language `description`,
* and the console's ingestion step warned once per such field (#21948). It is
* ⛔ not served on `inlineHelpText` either, which the extractor never reads.
*
* `description` is a key the catalog had never touched, so it follows the
* rule above, as every string this translator added since does: the catalog
* applies only while the served field's `description` still equals the same
* field's in {@link TranslateDocumentOptions.packagedBase}, judged by
* {@link valueOverridesPackagedBase} (⛔ never a second comparison). A field
* the base does not declare was authored after the fact, and its description
* counts as diverged; no base supplied means nothing is inferred and the
* catalog applies.
*/
export function translateObject<T extends ObjectLike>(
doc: T,
Expand All @@ -2982,15 +3040,21 @@ export function translateObject<T extends ObjectLike>(
const label = resolveScalar('label', doc.label);
const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel);
const description = resolveScalar('description', doc.description);
const packagedObject = asRecord(opts?.packagedBase);

const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => {
const next: ObjectFieldLike = { ...def };
const translatedLabel =
lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ??
builtinSystemFieldLabel(name, def.label, opts);
if (translatedLabel) next.label = translatedLabel;
const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts);
if (translatedHelp) next.help = translatedHelp;
// The bundle's field `help` entry translates `description`, and is served
// there — never on an undeclared `help` — unless the served description
// diverged from the packaged field's (ADR-0029 D9.2a; see the docblock).
if (!valueOverridesPackagedBase(packagedObjectField(packagedObject, name), 'description', def.description)) {
const translatedDescription = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts);
if (translatedDescription) next.description = translatedDescription;
}
if (Array.isArray(def.options)) {
// A picklist-bound field is served with its list's options resolved
// onto it (`PicklistServedFieldSchema`), and INHERITS the list's option
Expand Down
Loading