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
20 changes: 20 additions & 0 deletions .changeset/30391-extension-rename-drops-stale-plural.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/objectql': minor
'@objectstack/rest': minor
---

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)

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.
Metadata-protocol: semantic
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).

<!-- 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. -->

**BREAKING** served-value narrowing for an object an extension renames.

- **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.
- **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).
- **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.

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.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ check that spelling first.
| `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). |
| `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. |
| `indexes` | **Concatenated**, same as validations. |
| `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). |
| `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. |
| `pluralLabel` | Same as `label`. |
| `description` | Same as `label`. |
| `priority` | Not merged — it *orders* the merge. `0`–`999`, default `200`. |
Expand Down
5 changes: 4 additions & 1 deletion content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ in no package. `GET /meta/:type/:name`, `GET /meta/:type` and the book tree
Objects follow the same rule for their `label`, `pluralLabel` and
`description`: a value that differs from the owning package's declaration (a
label an [object extension](/docs/data-modeling/object-extensions) contributes,
for one) is served as written.
for one) is served as written. An extension that renames the object and sets
no `pluralLabel` is served with no plural in every locale, since the catalog's
`pluralLabel` translates the old name, unless the owning package's `.custom`
pair saves a translation of `objects.<name>.pluralLabel`: that one is served.

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
Expand Down
130 changes: 130 additions & 0 deletions packages/objectql/src/registry-extension-plural-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #30391 — `pluralLabel` is the plural OF `label`. An object extension that
* renames the object (`label`) and declares no `pluralLabel` leaves the running
* plural naming the old label, so the fold (`mergeObjectDefinitions`) takes it
* off and the object is served as one that declares no `pluralLabel`.
*
* What stays: an extension's own plural; the base's plural under an extension
* that renames nothing (no `label`, or the same one); a plural the tenant
* authored (the ADR-0029 D9.2a yield, which a tenant-authored `label` also
* takes); the packaged base the translators compare against.
*
* The served answer — `/meta/object` in `en` and `zh-CN`, the catalog's plural
* included — is pinned in `@objectstack/rest`'s
* `meta-object-extension-plural-label.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { SchemaRegistry } from './registry.js';

const BASE = 'com.acme.crm';
const PAIR = 'com.acme.crm.custom';
const PARTNER = 'com.partner.crm-ext';

const TASK = {
name: 'crm_task',
label: 'Task',
pluralLabel: 'Tasks',
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
};

const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;

function registry(): SchemaRegistry {
const r = new SchemaRegistry({ multiTenant: false });
r.logLevel = 'silent';
r.registerObject(clone(TASK) as never, BASE, undefined, 'own');
return r;
}

const served = (r: SchemaRegistry) => r.getObject('crm_task') as unknown as Record<string, unknown>;
const scalars = (doc: Record<string, unknown>) => ({
label: doc.label,
pluralLabel: doc.pluralLabel,
hasPluralLabel: Object.prototype.hasOwnProperty.call(doc, 'pluralLabel'),
});

describe('#30391 — an extension that renames only label', () => {
it('is served with the new label and no pluralLabel', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false });
});

it('leaves the packaged base its plural, which the translators compare against', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
const base = r.getPackagedObjectBase('crm_task') as unknown as Record<string, unknown>;
expect(scalars(base)).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
expect(base._packageId).toBe(BASE);
});

it('is idempotent over an already-folded base', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
const once = served(r);
const twice = r.foldObjectExtendersOnto('crm_task', clone(once) as never) as unknown as Record<string, unknown>;
expect(scalars(twice)).toEqual(scalars(once));
});
});

describe('#30391 — controls: the plural stays', () => {
it('an extension that sets pluralLabel is served as set', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', pluralLabel: 'Work Items' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true });
});

it('an extension that sets only pluralLabel keeps the label', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Work Items', hasPluralLabel: true });
});

it('an extension that changes neither keeps the base plural', () => {
const r = registry();
r.registerObjectExtension(
{ extend: 'crm_task', fields: { region__c: { name: 'region__c', type: 'text', label: 'Region' } } },
PAIR,
);
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
});

it('an extension that repeats the label it extends renames nothing', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Task' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Task', pluralLabel: 'Tasks', hasPluralLabel: true });
});

it('a plural the tenant authored stays under a rename (ADR-0029 D9.2a)', () => {
const r = registry();
r.registerObject({ ...clone(TASK), pluralLabel: 'To-dos', _provenance: 'org' } as never, BASE, undefined, 'overlay');
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'To-dos', hasPluralLabel: true });
});

it('a label the tenant authored makes the extension\'s rename yield, plural and all', () => {
const r = registry();
r.registerObject({ ...clone(TASK), label: 'Job', _provenance: 'org' } as never, BASE, undefined, 'overlay');
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item' }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Job', pluralLabel: 'Tasks', hasPluralLabel: true });
});
});

describe('#30391 — two extenders, in priority order', () => {
it('a later rename with no plural takes the earlier extender\'s plural off too', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Job', pluralLabel: 'Jobs', priority: 150 }, PARTNER);
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 250 }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: undefined, hasPluralLabel: false });
});

it('a later plural is served over an earlier rename', () => {
const r = registry();
r.registerObjectExtension({ extend: 'crm_task', label: 'Work Item', priority: 150 }, PARTNER);
r.registerObjectExtension({ extend: 'crm_task', pluralLabel: 'Work Items', priority: 250 }, PAIR);
expect(scalars(served(r))).toEqual({ label: 'Work Item', pluralLabel: 'Work Items', hasPluralLabel: true });
});
});
16 changes: 14 additions & 2 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,9 @@ function isLeftOutExtension(contributor: ObjectContributor, options: ObjectExten
* the tenant's `message` and `active`, never the tenant's condition.
* - The three {@link OBJECT_FOLD_SCALAR_KEYS} (`label`, `pluralLabel`,
* `description`) are overridden last-writer-wins, subject to the
* `tenantAuthored` yield rule below.
* `tenantAuthored` yield rule below. [#30391] A `label` that renames the
* object without a `pluralLabel` beside it also takes the running
* `pluralLabel` off: the plural named the old label.
* - `fieldPatches` [ADR-0146 D10, second rule] is APPLIED, never carried:
* after the merges above, each patch writes its `BASE_FIELD_DISPLAY_WHITELIST`
* keys onto the named field and its option labels onto the options whose
Expand Down Expand Up @@ -843,7 +845,17 @@ function mergeObjectDefinitions(
// Override scalar props (last writer wins) — [ADR-0029 D9.2a] unless the base has been
// authored by the tenant, in which case the extender's packaged default yields.
const yields = (key: ObjectFoldScalarKey): boolean => tenantAuthored?.has(key) === true;
if (extension.label !== undefined && !yields('label')) merged.label = extension.label;
if (extension.label !== undefined && !yields('label')) {
// [#30391] `pluralLabel` is the plural OF `label`: an extension that renames
// the object and declares no plural leaves the running plural naming the
// old label, so the object is served as one that declares no
// `pluralLabel`. A plural the tenant authored stays (the D9.2a yield), and
// an extension that repeats the label it extends renames nothing.
if (extension.pluralLabel === undefined && extension.label !== merged.label && !yields('pluralLabel')) {
delete merged.pluralLabel;
}
merged.label = extension.label;
}
if (extension.pluralLabel !== undefined && !yields('pluralLabel')) {
merged.pluralLabel = extension.pluralLabel;
}
Expand Down
99 changes: 95 additions & 4 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3034,7 +3034,23 @@ export async function pairTranslationBundleOf(
} catch {
return undefined;
}
if (typeof base !== 'string' || base === '') return undefined;
return pairTranslationBundleOfBase(p, base, memo);
}

/**
* `getPairTranslationBundle(base)` on `protocol`, read once per base through
* `memo` — {@link pairTranslationBundleOf}'s second question, also asked by
* {@link withoutStaleObjectPlural} for the base an object's owner ships in.
* `undefined` on every uncertainty, {@link pairTranslationBundleOf}'s rule.
*/
function pairTranslationBundleOfBase(
protocol: unknown,
base: unknown,
memo?: Map<string, Promise<unknown>>,
): Promise<unknown> {
const p: any = protocol;
if (typeof base !== 'string' || base === '') return Promise.resolve(undefined);
if (!p || typeof p.getPairTranslationBundle !== 'function') return Promise.resolve(undefined);
let pending = memo?.get(base);
if (pending === undefined) {
pending = Promise.resolve()
Expand All @@ -3045,6 +3061,66 @@ export async function pairTranslationBundleOf(
return pending;
}

/** A non-empty string, or `undefined`. */
function nonEmptyText(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

/**
* [#30391] `translated` — what the object translator answered for the served
* object `input` — without a plural the catalog made up for a renamed object.
*
* An object extension that renames `label` and declares no `pluralLabel` is
* served without one (the fold takes the plural of the old label off,
* `mergeObjectDefinitions` in `@objectstack/objectql`). The translator 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: it names the old label. So where `input` carries no plural, the
* packaged base declares one, and `input`'s `label` diverged from the packaged
* one, the catalog's plural is not served. In its place, the ruled order
* (ruling record 6631250894: a translation in the pair, then the override's
* own text, then the base's translation): the base's pair's own translation of
* the plural when it ships one, read through the same translator over the
* pair's bundle alone; otherwise what `input` carries, which is no plural —
* what an object that declares no `pluralLabel` is served.
*
* An object served WITH a plural (an extension that sets one, a tenant rename
* that kept one) and an object whose label is the packaged one are served
* unchanged, as is every object with no packaged base. Returns `translated`
* itself when nothing changes; the pair is read only when the rule applies.
*/
async function withoutStaleObjectPlural(
protocol: unknown,
input: Record<string, unknown>,
translated: Record<string, unknown>,
packagedBase: unknown,
translateOptions: Record<string, unknown>,
memo?: Map<string, Promise<unknown>>,
): Promise<Record<string, unknown>> {
if (nonEmptyText(input.pluralLabel) !== undefined) return translated;
const packaged = packagedBase && typeof packagedBase === 'object' ? packagedBase as Record<string, unknown> : undefined;
if (!packaged || nonEmptyText(packaged.pluralLabel) === undefined) return translated;
const label = nonEmptyText(input.label);
if (label === undefined || label === packaged.label) return translated;
const pairBundle = await pairTranslationBundleOfBase(protocol, packaged._packageId, memo);
let pairPlural: string | undefined;
if (pairBundle !== undefined && typeof input.name === 'string') {
const { translateMetadataDocument } = await import('@objectstack/spec/system');
const read = translateMetadataDocument('object', { name: input.name } as any, pairBundle as any, translateOptions as any);
pairPlural = nonEmptyText((read as Record<string, unknown> | undefined)?.pluralLabel);
}
if (pairPlural !== undefined) {
return translated.pluralLabel === pairPlural ? translated : { ...translated, pluralLabel: pairPlural };
}
if (!('pluralLabel' in input)) {
if (!('pluralLabel' in translated)) return translated;
const served: Record<string, unknown> = { ...translated };
delete served.pluralLabel;
return served;
}
return translated.pluralLabel === input.pluralLabel ? translated : { ...translated, pluralLabel: input.pluralLabel };
}

/** How a transport reaches what the list translation reads. */
export interface MetaListTranslationSources {
/** This request's i18n service, or `undefined` when the deployment has none. */
Expand Down Expand Up @@ -3110,12 +3186,19 @@ export async function translateMetaList(
// OUTER `{ type, items }`), so every element translates directly (#5563).
const translated = await Promise.all(arr.map(async (item) => {
const pairBundle = p === undefined ? undefined : await pairTranslationBundleOf(p, metaType, item, pairBundles);
const packagedBase = packagedObjectBaseOf(p, metaType, item?.name);
const out = translateMetadataDocument(metaType, item, bundle, {
...metaTranslateOptions(i18n, locale),
packagedBase: packagedObjectBaseOf(p, metaType, item?.name),
packagedBase,
...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}),
});
return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out;
if (!item || typeof item !== 'object') return out;
if (metaType === 'view') return withoutInventedViewLabel(item, out);
// [#30391] A renamed object's plural, read once per base for the page.
if (metaType === 'object') {
return withoutStaleObjectPlural(p, item, out, packagedBase, metaTranslateOptions(i18n, locale), pairBundles);
}
return out;
}));
return Array.isArray(raw) ? translated : { ...raw, items: translated };
}
Expand Down Expand Up @@ -3183,7 +3266,15 @@ export async function translateMetaDocument(
packagedBase,
...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}),
});
return metaType === 'view' ? withoutInventedViewLabel(document as Record<string, unknown>, translated) : translated;
if (metaType === 'view') return withoutInventedViewLabel(document as Record<string, unknown>, translated);
if (metaType === 'object' && translated && typeof translated === 'object') {
// [#30391] A renamed object's plural.
return withoutStaleObjectPlural(
p, document as Record<string, unknown>, translated as Record<string, unknown>, packagedBase,
metaTranslateOptions(i18n, locale),
);
}
return translated;
}

/**
Expand Down
Loading
Loading