Skip to content

Commit fb1ab89

Browse files
fix(objectql,rest)!: serve an object an extension renames without the base's plural (#30391) (#30459)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg1JP2CcGNs5hTdBvvidGk1x)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:对象扩展(`.custom` 配对包里的 `object_extension`,或代码包的 `objectExtensions`)只改了 `label`、没写 `pluralLabel` 时,`/meta/object` 不再返回基础包的复数名,也不再返回基础包对旧复数的译文;对象按“没声明复数”的样子返回,列表标题就显示新名字。配对包自己保存了该复数的译文时,返回配对包的译文。 - 为什么改:客户改了对象名,侧栏和列表标题(先读复数)还是旧名字,一个对象两个名字;中文下尤其明显,因为旧复数还被翻成了“任务”。 - 风险与回滚:这是一次已返回值的收窄(BREAKING):所有只改 label 的扩展都会少返回 pluralLabel,包括 showcase 自带的 `showcase_account`(列表标题从 “Accounts” 变成 “Account (Success Overlay)”)。没有数据迁移,回滚即撤本 PR。 - 先看:`packages/objectql/src/registry.ts` 的 `mergeObjectDefinitions`(标量覆盖那一段),再看 `packages/rest/src/meta-item-read-gate.ts` 的 `withoutStaleObjectPlural` 和它在 `translateMetaList` / `translateMetaDocument` 里的两处调用。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/meta-object-extension-plural-label.test.ts`,预期 11 条全绿;修复前的代码上有 4 条是红的。 - 需要你做的:无,席位落地(非受治理面,narrowing 已过达档合同复核 PASS 6658126879)。 Fixes #30391 Clause-②: no (narrowing) — `/meta/object` (item and list reads, both transports) and every registry read of an object (`SchemaRegistry.getObject`, the extension fold) stop serving `pluralLabel` for an object an extension renames without a plural of its own, where they served the base's plural (and, in a translated locale, the base's translation of it). The shipped showcase's `showcase_account` is one. Nothing an author writes is refused, and no exported symbol moves. Metadata-protocol: semantic Skills-docs: follow-up #30466 — `skills/objectstack-data/SKILL.md` "Object Extension Model" says an extension "can add but never remove"; a renaming `label` now takes the base's `pluralLabel` off. The sentence wants that exception (`skills/**` is outside this change's surface). ## What changes `pluralLabel` is the plural of `label`. The seat's route (served side, under ruling record 6631250894's label order): an extension that changes `label` without `pluralLabel` serves what an object with no `pluralLabel` serves, the pair's own translation first. The new pin measures that reference on the same boot (`crm_note`, which declares no plural): it is served with no `pluralLabel` key in `en` and in `zh-CN`, and the Console then titles a list by the label (`useNavTargetLabel`'s `objectPluralLabel`: served plural, else served label). Two halves, and each one alone leaves a locale red (Ablations below): - **The fold** (`mergeObjectDefinitions`, `@objectstack/objectql`). When an extension's `label` is applied (it does not yield to a tenant-authored label, ADR-0029 D9.2a), differs from the running label, and the extension sets no `pluralLabel`, the running `pluralLabel` is deleted. A plural the tenant authored stays (the same D9.2a yield). The fold stays idempotent over an already-folded base: the second pass yields on the label and leaves the plural absent. `getPackagedObjectBase` is untouched and keeps the owner's plural, which the translators compare against. - **The `/meta` read** (`withoutStaleObjectPlural`, `@objectstack/rest`). The spec's `translateObject` answers a missing `pluralLabel` with the catalog's `objects.NAME.pluralLabel`, and that entry is the base package's translation of the plural the extension left behind (the `en` catalog repeats the source text, so `en` is affected too). Where the served object carries no plural, the packaged base declares one, and the served label diverged from the packaged label, the catalog's plural is replaced by the owner package's `.custom` pair's own translation of `objects.NAME.pluralLabel` (`getPairTranslationBundle(_packageId of the packaged base)`, read once per base per request, translated through `translateMetadataDocument` over the pair's bundle alone), or dropped when the pair has none. Every other object is returned unchanged, and the pair is read only when the rule applies. It runs on both `translateMetaList` and `translateMetaDocument`, which both transports use. No spec change: the catalog lookup stays the spec's; the server decides what it serves. No ADR text changes: ADR-0146 D10 says an object is extended, never replaced; the base's declaration is untouched and only the served fold changes. ## Tests - `packages/rest/src/meta-object-extension-plural-label.test.ts` (new, real `SchemaRegistry` + real `ObjectStackProtocolImplementation` + `RestServer`, i18n service over a deployment catalog with the pair's row merged in, both reads, `en` and `zh-CN`): the reference (`crm_note`, no plural); a label-only rename serves what the reference serves; the pair's `zh-CN` plural is served and `en` gets none; a pair row translating only `label` does not let the base's plural back; controls: label + plural served as set, plural only, neither (base plural and `任务` kept), repeated label, an untouched object. 4 red on main `09720e2c` (en and zh-CN rename, pair, non-pair), 11/11 after. - `packages/objectql/src/registry-extension-plural-label.test.ts` (new, the fold): rename drops the plural; packaged base keeps `Tasks` and `_packageId`; idempotent refold; plural set, plural only, fields only, repeated label; tenant-authored plural stays; tenant-authored label makes the rename yield; two extenders in priority order both ways. 11/11. - Suites at `f71f7bdc` (after merging `origin/main`; the two later commits touch only the changeset and the engine-double ledger): `@objectstack/objectql` 551 files, 10983 passed; `@objectstack/rest` 387 files, 7346 passed / 332 skipped; `typecheck` for both exit 0 (including `check:test-typecheck`); eslint on the four changed source/test files exit 0. ## Ablations Through `scripts/ablation-replace.mjs`, each restored to blob == HEAD with `git diff HEAD` empty. | | Mutation | Red | |:--|:--|:--| | A | the fold's `delete merged.pluralLabel` guarded off (rebuilt; `ablation-dist-preflight.mjs` exit 0, then `--absent` exit 0 after the restore rebuild) | objectql 2/11, rest 4/11 | | B | `withoutStaleObjectPlural` returns the translator's answer unchanged (rest tests import `src`) | rest 4/11 (both locales: the `en` catalog repeats the source plural) | ## Acceptance notes - **Two names on objects still.** For objects the served `label` reads no pair translation (PR #30353 left objects out of `pairBundle`, seat ruling: an object is extended, never overridden). The plural now reads the pair's translation per this card's route, so a pair that translates both strings gets its plural translated and its label served as written. Carrier: seat (`domain:server#5`). - **Studio's rename.** A tenant overlay that renames an object and keeps the old `pluralLabel` is untouched (the tenant authored that body, and #8284's pin keeps its plural translated). Carrier: seat, not filed. - `form-routes.ts` (public form views) translates the form's object schema through `translateMetadataDocument` on its own path, outside this change's surface; for such an object that schema can still carry the catalog's plural. Not measured. Carrier: seat. ## Verification <details><summary>Derived gates, dogfood, lint — final head a1107d3</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f71f7bdc`: 91 commands; the ledger commit added 8 (99). Each run with its exit code captured before any pipe. 94 exit 0 on the first pass. Five did not: - `check-adr-0087-registration --base origin/main` exit 1: a `FROM → TO` block contradicted `not-required (no-migration-prescription)`. Nothing has to be rewritten, so the block became a sentence saying so; re-run exit 0. - `check:engine-double-contract` exit 1: the new pin's `findOne` double was not in the ledger; `node scripts/check-engine-double-contract.mjs --write` added one row (`scripts/engine-double-contract.pinned.json`, +5); re-run exit 0. - `check-plugin-teardown-shape --self-test`, `check:skill-examples`, `check:dual-build-cjs-loads` exit 3 (prerequisite): exit 0 after fetching the pinned commit and a whole-workspace build. - `dispatch-gates --ran` at `a1107d3c6`: 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - `pnpm lint` (`eslint . --no-inline-config`) at `a1107d3c6`: exit 0. - Full dogfood (`Clause-②` carries the narrowing arm), `pnpm --filter @objectstack/dogfood test` under `os-verify-lock.sh` at `f71f7bdc` (same code as the final head): `VERDICT command-exit 0`, Test Files 419 passed / 3 skipped (422), Tests 3492 passed / 36 skipped (3528), 0 failures. - Ablation legs: see Ablations. CI check-runs: not read at the time of writing. </details> ## Docs `content/docs/data-modeling/object-extensions.mdx`: the merge table's `label` row now says a new label without a plural takes the target's plural off. `content/docs/ui/translations.mdx`: the object paragraph of "An edit beats the packaged catalog" now says such an object is served with no plural unless the owning package's `.custom` pair translates it. `releases/**` untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYaDMYnWxbm95qon7G)_ <!-- signed: domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d7f906 commit fb1ab89

8 files changed

Lines changed: 536 additions & 8 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/rest': minor
4+
---
5+
6+
fix(objectql,rest)!: an object extension that renames `label` and sets no `pluralLabel` is served without the base's plural, so list titles stop showing the old name (#30391)
7+
8+
Clause-②: no (narrowing) — `/meta/object` (item and list reads, both transports) and every registry read of an object (`SchemaRegistry.getObject`, the fold) stop serving `pluralLabel` for an object an extension renames without a plural of its own, where they served the base's plural (and, in a translated locale, the base's translation of it). The shipped showcase's `showcase_account` is one: its extension renames it `Account (Success Overlay)` and declares no plural. Nothing an author writes is refused.
9+
Metadata-protocol: semantic
10+
Skills-docs: follow-up — `skills/objectstack-data/SKILL.md` "Object Extension Model" says an extension "can add but never remove"; a renaming `label` now takes the base's `pluralLabel` off. The sentence wants that exception (outside this change's surface).
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: an object extension declares `label` and `pluralLabel` exactly as before. What moves is the plural an object is served with when an extension renames it without one, and nothing at rest records that answer, so there is nothing for a migration to rewrite. -->
13+
14+
**BREAKING** served-value narrowing for an object an extension renames.
15+
16+
- **Before.** An `objectExtensions` entry or a `.custom` `object_extension` item that set `label` and no `pluralLabel` was folded with the new label and the base's `pluralLabel`. A translated read served the base package's translation of that plural (`objects.NAME.pluralLabel`), so a list title, read plural first, kept the old name in every locale.
17+
- **Now.** `pluralLabel` is the plural of `label`: such an extension is served as an object that declares no `pluralLabel`, in every locale, so a list titles it by its label. Where the owning package's `.custom` pair saves its own translation of `objects.NAME.pluralLabel`, that translation is served (ruling record 6631250894: a translation in the pair, then the override's own text, then the base's translation).
18+
- **Unchanged.** An extension that sets `pluralLabel` is served with it. One that sets no `label`, or repeats the label it extends, keeps the base's plural and its translation. A plural the tenant set stays, and a tenant's own rename yields as before (ADR-0029 D9.2a). An object no extension renames is served as before. The packaged base the translators compare against keeps its plural.
19+
20+
Nothing has to be rewritten: an extension that wants a plural for its new name declares `pluralLabel` beside `label`, as it always could, and a `.custom` pair can save `objects.NAME.pluralLabel` in its own `translation` item for a translated locale.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ check that spelling first.
6666
| `fieldPatches` | **Applied, field by field.** Changes the *display* of one of the target's own fields — never one an extension adds — without replacing it — [below](#relabelling-a-vendor-field). |
6767
| `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. |
6868
| `indexes` | **Concatenated**, same as validations. |
69-
| `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). |
69+
| `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). A new `label` with no `pluralLabel` beside it also takes the target's plural off, since that plural names the old label: the object is served as one that declares no plural, and lists title it by its label. Set `pluralLabel` too to serve one. A plural the tenant set stays. |
7070
| `pluralLabel` | Same as `label`. |
7171
| `description` | Same as `label`. |
7272
| `priority` | Not merged — it *orders* the merge. `0`–`999`, default `200`. |

‎content/docs/ui/translations.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,10 @@ in no package. `GET /meta/:type/:name`, `GET /meta/:type` and the book tree
235235
Objects follow the same rule for their `label`, `pluralLabel` and
236236
`description`: a value that differs from the owning package's declaration (a
237237
label an [object extension](/docs/data-modeling/object-extensions) contributes,
238-
for one) is served as written.
238+
for one) is served as written. An extension that renames the object and sets
239+
no `pluralLabel` is served with no plural in every locale, since the catalog's
240+
`pluralLabel` translates the old name, unless the owning package's `.custom`
241+
pair saves a translation of `objects.<name>.pluralLabel`: that one is served.
239242

240243
An object's validation rule messages follow it too, on both places a reader
241244
sees them: the error a rejected write returns, and the rule served with the
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #30391 — `pluralLabel` is the plural OF `label`. An object extension that
5+
* renames the object (`label`) and declares no `pluralLabel` leaves the running
6+
* plural naming the old label, so the fold (`mergeObjectDefinitions`) takes it
7+
* off and the object is served as one that declares no `pluralLabel`.
8+
*
9+
* What stays: an extension's own plural; the base's plural under an extension
10+
* that renames nothing (no `label`, or the same one); a plural the tenant
11+
* authored (the ADR-0029 D9.2a yield, which a tenant-authored `label` also
12+
* takes); the packaged base the translators compare against.
13+
*
14+
* The served answer — `/meta/object` in `en` and `zh-CN`, the catalog's plural
15+
* included — is pinned in `@objectstack/rest`'s
16+
* `meta-object-extension-plural-label.test.ts`.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { SchemaRegistry } from './registry.js';
21+
22+
const BASE = 'com.acme.crm';
23+
const PAIR = 'com.acme.crm.custom';
24+
const PARTNER = 'com.partner.crm-ext';
25+
26+
const TASK = {
27+
name: 'crm_task',
28+
label: 'Task',
29+
pluralLabel: 'Tasks',
30+
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
31+
};
32+
33+
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
34+
35+
function registry(): SchemaRegistry {
36+
const r = new SchemaRegistry({ multiTenant: false });
37+
r.logLevel = 'silent';
38+
r.registerObject(clone(TASK) as never, BASE, undefined, 'own');
39+
return r;
40+
}
41+
42+
const served = (r: SchemaRegistry) => r.getObject('crm_task') as unknown as Record<string, unknown>;
43+
const scalars = (doc: Record<string, unknown>) => ({
44+
label: doc.label,
45+
pluralLabel: doc.pluralLabel,
46+
hasPluralLabel: Object.prototype.hasOwnProperty.call(doc, 'pluralLabel'),
47+
});
48+
49+
describe('#30391 — an extension that renames only label', () => {
50+
it('is served with the new label and no pluralLabel', () => {
51+
const r = registry();
52+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
53+
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false });
54+
});
55+
56+
it('leaves the packaged base its plural, which the translators compare against', () => {
57+
const r = registry();
58+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
59+
const base = r.getPackagedObjectBase('crm_task') as unknown as Record<string, unknown>;
60+
expect(scalars(base)).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
61+
expect(base._packageId).toBe(BASE);
62+
});
63+
64+
it('is idempotent over an already-folded base', () => {
65+
const r = registry();
66+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
67+
const once = served(r);
68+
const twice = r.foldObjectExtendersOnto('crm_task', clone(once) as never) as unknown as Record<string, unknown>;
69+
expect(scalars(twice)).toEqual(scalars(once));
70+
});
71+
});
72+
73+
describe('#30391 — controls: the plural stays', () => {
74+
it('an extension that sets pluralLabel is served as set', () => {
75+
const r = registry();
76+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', pluralLabel: 'Work Items' }, PAIR);
77+
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true });
78+
});
79+
80+
it('an extension that sets only pluralLabel keeps the label', () => {
81+
const r = registry();
82+
r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items' }, PAIR);
83+
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Work Items', hasPluralLabel: true });
84+
});
85+
86+
it('an extension that changes neither keeps the base plural', () => {
87+
const r = registry();
88+
r.registerObjectExtension(
89+
{ extend: 'crm_task', fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } } },
90+
PAIR,
91+
);
92+
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
93+
});
94+
95+
it('an extension that repeats the label it extends renames nothing', () => {
96+
const r = registry();
97+
r.registerObjectExtension({ extend: 'crm_task', label: 'Task' }, PAIR);
98+
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
99+
});
100+
101+
it('a plural the tenant authored stays under a rename (ADR-0029 D9.2a)', () => {
102+
const r = registry();
103+
r.registerObject({ ...clone(TASK), pluralLabel: 'To-dos', _provenance: 'org' } as never, BASE, undefined, 'overlay');
104+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
105+
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'To-dos', hasPluralLabel: true });
106+
});
107+
108+
it('a label the tenant authored makes the extension\'s rename yield, plural and all', () => {
109+
const r = registry();
110+
r.registerObject({ ...clone(TASK), label: 'Job', _provenance: 'org' } as never, BASE, undefined, 'overlay');
111+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
112+
expect(scalars(served(r))).toEqual({ label: 'Job', pluralLabel: 'Tasks', hasPluralLabel: true });
113+
});
114+
});
115+
116+
describe('#30391 — two extenders, in priority order', () => {
117+
it('a later rename with no plural takes the earlier extender\'s plural off too', () => {
118+
const r = registry();
119+
r.registerObjectExtension({ extend: 'crm_task', label: 'Job', pluralLabel: 'Jobs', priority: 150 }, PARTNER);
120+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 250 }, PAIR);
121+
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false });
122+
});
123+
124+
it('a later plural is served over an earlier rename', () => {
125+
const r = registry();
126+
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 150 }, PARTNER);
127+
r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items', priority: 250 }, PAIR);
128+
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true });
129+
});
130+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,9 @@ function withoutWithheldMembers(base: ServiceObject, withheld: WithheldExtension
825825
* the tenant's `message` and `active`, never the tenant's condition.
826826
* - The three {@link OBJECT_FOLD_SCALAR_KEYS} (`label`, `pluralLabel`,
827827
* `description`) are overridden last-writer-wins, subject to the
828-
* `tenantAuthored` yield rule below.
828+
* `tenantAuthored` yield rule below. [#30391] A `label` that renames the
829+
* object without a `pluralLabel` beside it also takes the running
830+
* `pluralLabel` off: the plural named the old label.
829831
* - `fieldPatches` [ADR-0146 D10, second rule] is APPLIED, never carried:
830832
* after the merges above, each patch writes its `BASE_FIELD_DISPLAY_WHITELIST`
831833
* keys onto the named field and its option labels onto the options whose
@@ -905,7 +907,17 @@ function mergeObjectDefinitions(
905907
// Override scalar props (last writer wins) — [ADR-0029 D9.2a] unless the base has been
906908
// authored by the tenant, in which case the extender's packaged default yields.
907909
const yields = (key: ObjectFoldScalarKey): boolean => tenantAuthored?.has(key) === true;
908-
if (extension.label !== undefined && !yields('label')) merged.label = extension.label;
910+
if (extension.label !== undefined && !yields('label')) {
911+
// [#30391] `pluralLabel` is the plural OF `label`: an extension that renames
912+
// the object and declares no plural leaves the running plural naming the
913+
// old label, so the object is served as one that declares no
914+
// `pluralLabel`. A plural the tenant authored stays (the D9.2a yield), and
915+
// an extension that repeats the label it extends renames nothing.
916+
if (extension.pluralLabel === undefined && extension.label !== merged.label && !yields('pluralLabel')) {
917+
delete merged.pluralLabel;
918+
}
919+
merged.label = extension.label;
920+
}
909921
if (extension.pluralLabel !== undefined && !yields('pluralLabel')) {
910922
merged.pluralLabel = extension.pluralLabel;
911923
}

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3034,7 +3034,23 @@ export async function pairTranslationBundleOf(
30343034
} catch {
30353035
return undefined;
30363036
}
3037-
if (typeof base !== 'string' || base === '') return undefined;
3037+
return pairTranslationBundleOfBase(p, base, memo);
3038+
}
3039+
3040+
/**
3041+
* `getPairTranslationBundle(base)` on `protocol`, read once per base through
3042+
* `memo` — {@link pairTranslationBundleOf}'s second question, also asked by
3043+
* {@link withoutStaleObjectPlural} for the base an object's owner ships in.
3044+
* `undefined` on every uncertainty, {@link pairTranslationBundleOf}'s rule.
3045+
*/
3046+
function pairTranslationBundleOfBase(
3047+
protocol: unknown,
3048+
base: unknown,
3049+
memo?: Map<string, Promise<unknown>>,
3050+
): Promise<unknown> {
3051+
const p: any = protocol;
3052+
if (typeof base !== 'string' || base === '') return Promise.resolve(undefined);
3053+
if (!p || typeof p.getPairTranslationBundle !== 'function') return Promise.resolve(undefined);
30383054
let pending = memo?.get(base);
30393055
if (pending === undefined) {
30403056
pending = Promise.resolve()
@@ -3045,6 +3061,66 @@ export async function pairTranslationBundleOf(
30453061
return pending;
30463062
}
30473063

3064+
/** A non-empty string, or `undefined`. */
3065+
function nonEmptyText(value: unknown): string | undefined {
3066+
return typeof value === 'string' && value.length > 0 ? value : undefined;
3067+
}
3068+
3069+
/**
3070+
* [#30391] `translated` — what the object translator answered for the served
3071+
* object `input` — without a plural the catalog made up for a renamed object.
3072+
*
3073+
* An object extension that renames `label` and declares no `pluralLabel` is
3074+
* served without one (the fold takes the plural of the old label off,
3075+
* `mergeObjectDefinitions` in `@objectstack/objectql`). The translator answers
3076+
* a missing `pluralLabel` with the catalog's `objects.NAME.pluralLabel`, and
3077+
* that entry is the base package's translation of the plural the extension
3078+
* left behind: it names the old label. So where `input` carries no plural, the
3079+
* packaged base declares one, and `input`'s `label` diverged from the packaged
3080+
* one, the catalog's plural is not served. In its place, the ruled order
3081+
* (ruling record 6631250894: a translation in the pair, then the override's
3082+
* own text, then the base's translation): the base's pair's own translation of
3083+
* the plural when it ships one, read through the same translator over the
3084+
* pair's bundle alone; otherwise what `input` carries, which is no plural —
3085+
* what an object that declares no `pluralLabel` is served.
3086+
*
3087+
* An object served WITH a plural (an extension that sets one, a tenant rename
3088+
* that kept one) and an object whose label is the packaged one are served
3089+
* unchanged, as is every object with no packaged base. Returns `translated`
3090+
* itself when nothing changes; the pair is read only when the rule applies.
3091+
*/
3092+
async function withoutStaleObjectPlural(
3093+
protocol: unknown,
3094+
input: Record<string, unknown>,
3095+
translated: Record<string, unknown>,
3096+
packagedBase: unknown,
3097+
translateOptions: Record<string, unknown>,
3098+
memo?: Map<string, Promise<unknown>>,
3099+
): Promise<Record<string, unknown>> {
3100+
if (nonEmptyText(input.pluralLabel) !== undefined) return translated;
3101+
const packaged = packagedBase && typeof packagedBase === 'object' ? packagedBase as Record<string, unknown> : undefined;
3102+
if (!packaged || nonEmptyText(packaged.pluralLabel) === undefined) return translated;
3103+
const label = nonEmptyText(input.label);
3104+
if (label === undefined || label === packaged.label) return translated;
3105+
const pairBundle = await pairTranslationBundleOfBase(protocol, packaged._packageId, memo);
3106+
let pairPlural: string | undefined;
3107+
if (pairBundle !== undefined && typeof input.name === 'string') {
3108+
const { translateMetadataDocument } = await import('@objectstack/spec/system');
3109+
const read = translateMetadataDocument('object', { name: input.name } as any, pairBundle as any, translateOptions as any);
3110+
pairPlural = nonEmptyText((read as Record<string, unknown> | undefined)?.pluralLabel);
3111+
}
3112+
if (pairPlural !== undefined) {
3113+
return translated.pluralLabel === pairPlural ? translated : { ...translated, pluralLabel: pairPlural };
3114+
}
3115+
if (!('pluralLabel' in input)) {
3116+
if (!('pluralLabel' in translated)) return translated;
3117+
const served: Record<string, unknown> = { ...translated };
3118+
delete served.pluralLabel;
3119+
return served;
3120+
}
3121+
return translated.pluralLabel === input.pluralLabel ? translated : { ...translated, pluralLabel: input.pluralLabel };
3122+
}
3123+
30483124
/** How a transport reaches what the list translation reads. */
30493125
export interface MetaListTranslationSources {
30503126
/** This request's i18n service, or `undefined` when the deployment has none. */
@@ -3110,12 +3186,19 @@ export async function translateMetaList(
31103186
// OUTER `{ type, items }`), so every element translates directly (#5563).
31113187
const translated = await Promise.all(arr.map(async (item) => {
31123188
const pairBundle = p === undefined ? undefined : await pairTranslationBundleOf(p, metaType, item, pairBundles);
3189+
const packagedBase = packagedObjectBaseOf(p, metaType, item?.name);
31133190
const out = translateMetadataDocument(metaType, item, bundle, {
31143191
...metaTranslateOptions(i18n, locale),
3115-
packagedBase: packagedObjectBaseOf(p, metaType, item?.name),
3192+
packagedBase,
31163193
...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}),
31173194
});
3118-
return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out;
3195+
if (!item || typeof item !== 'object') return out;
3196+
if (metaType === 'view') return withoutInventedViewLabel(item, out);
3197+
// [#30391] A renamed object's plural, read once per base for the page.
3198+
if (metaType === 'object') {
3199+
return withoutStaleObjectPlural(p, item, out, packagedBase, metaTranslateOptions(i18n, locale), pairBundles);
3200+
}
3201+
return out;
31193202
}));
31203203
return Array.isArray(raw) ? translated : { ...raw, items: translated };
31213204
}
@@ -3183,7 +3266,15 @@ export async function translateMetaDocument(
31833266
packagedBase,
31843267
...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}),
31853268
});
3186-
return metaType === 'view' ? withoutInventedViewLabel(document as Record<string, unknown>, translated) : translated;
3269+
if (metaType === 'view') return withoutInventedViewLabel(document as Record<string, unknown>, translated);
3270+
if (metaType === 'object' && translated && typeof translated === 'object') {
3271+
// [#30391] A renamed object's plural.
3272+
return withoutStaleObjectPlural(
3273+
p, document as Record<string, unknown>, translated as Record<string, unknown>, packagedBase,
3274+
metaTranslateOptions(i18n, locale),
3275+
);
3276+
}
3277+
return translated;
31873278
}
31883279

31893280
/**

0 commit comments

Comments
 (0)