diff --git a/.changeset/30062-declared-override-label-order.md b/.changeset/30062-declared-override-label-order.md
new file mode 100644
index 00000000000..f76e9f46e08
--- /dev/null
+++ b/.changeset/30062-declared-override-label-order.md
@@ -0,0 +1,14 @@
+---
+'@objectstack/metadata-protocol': minor
+'@objectstack/rest': minor
+---
+
+fix(metadata-protocol,rest): a `.custom` pair's declared override of a page, dataset, app, book, dashboard, view or report is labelled in the ruled order on every `/meta` read — the pair's own translation, then the override's own label, then the base's translation (#30062)
+
+Clause-②: yes (widening) — `ObjectStackProtocolImplementation` gains six public methods: `getPackagedPageBase`, `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`, `servedOverrideBaseOf` and `getPairTranslationBundle` (measured on `packages/metadata-protocol/dist/index.d.ts`); `@objectstack/rest`'s exported `MetaBookTreeSources` accepts one more optional key, `resolveProtocol` (measured on `packages/rest/dist/index.d.ts`). No accepted key, value or answer is taken away.
+Metadata-protocol: semantic
+
+- **Now.** A changed label on a declared override reads the pair's own translation of that key when the pair ships one, else the override's own text, in every locale, on `GET /meta/:type/:name`, `GET /meta/:type` and `GET /meta/book/:name/tree`. An unchanged label still reads the base's translation. Before, a page, dataset, app or book override read the base's translation over its own changed label, and no type read the pair's translation first.
+- **The pair's translations.** They are the pair's own active `translation` rows (`package_id` = the pair). A `.custom` package has no code bundle, so it has no code translation source. A package-less authored row is in the deployment catalog but is not the pair's, so it does not rank first.
+- **Object.** An object is extended, never replaced (ADR-0146 D10), so it has no declared override and no pair bundle: seven types take one.
+- **The book tree.** `GET /meta/book/:name/tree` now judges a book against its packaged base as the list does, so the docs sidebar and the book list serve the same label (#26690).
diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx
index a271f38bc8e..4269bfa009f 100644
--- a/content/docs/ui/translations.mdx
+++ b/content/docs/ui/translations.mdx
@@ -217,9 +217,21 @@ with the same string in the item as the package shipped it:
- **A widget, global filter or bulk action the package never shipped counts as
edited**: the org authored it, so its text is served as written.
- **An edited string is served in the language it was written in, to every
- reader.** The bundle entry for that string no longer applies to it.
+ reader**, unless the base's paired `.custom` package translates it (below).
+ The bundle entry for that string no longer applies to it.
- **Resetting the overlay restores the shipped string and its translation.**
+A declared override in the base's paired `.custom` package
+([ADR-0146](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146))
+is compared with the base's own item the same way, for a page, dataset, app
+and book as well as a dashboard, view and report, and the pair's own
+translations rank first. A string the override changed is served in the
+pair's translation of its key for that locale, else as written. A string it
+left alone takes the pair's translation, then the base's, locale by locale.
+Only `translation` items saved in the pair count, not an authored translation
+in no package. `GET /meta/:type/:name`, `GET /meta/:type` and the book tree
+`GET /meta/book/:name/tree` serve the same label.
+
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,
diff --git a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts
index 06a217ec2a3..12ae8e093f6 100644
--- a/packages/metadata-protocol/src/protocol-discovery-and-bases.ts
+++ b/packages/metadata-protocol/src/protocol-discovery-and-bases.ts
@@ -10,6 +10,12 @@
* `registeredTranslationBundle`, `readCodeLayerForCarryForward` moved out of
* `protocol.ts` whole by `scripts/codemod-split-protocol.mjs`.
*
+ * [#30062] Written here from the start, beside their siblings: the page,
+ * dataset, app and book packaged bases (`getPackagedPageBase`,
+ * `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`), the
+ * served-override reading of a served document (`servedOverrideBaseOf`) and the
+ * pair's own translations (`getPairTranslationBundle`).
+ *
* A MOVE, not a redesign. `ProtocolDiscoveryAndBasesFacet` is a stateless facet over the host
* (ruling 6624345766): each static method below is the body of the class
* member of the same name as `packages/metadata-protocol/scripts/host-split.mjs`
@@ -29,10 +35,12 @@
import type { SysMetadataRepository } from './sys-metadata-repository.js';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared';
import type { TranslationBundle } from '@objectstack/spec/system';
+import { readAuthoredTranslationLayer } from '@objectstack/core';
import { restoreTranslatedEchoes } from './protocol-translated-echo.js';
import type { RestoredTranslatedSlot } from './protocol-translated-echo.js';
import {
DEFAULT_METADATA_TYPE_REGISTRY,
+ customizationPackageId,
getMetadataCreateSeed,
getMetadataTypeActions,
getMetadataTypeSchema,
@@ -47,8 +55,16 @@ import {
} from './protocol-meta-type-schema.js';
import type { SaveMetaItemRequest } from './protocol-door-requests.js';
import type { ProtocolHost } from './protocol-host.js';
+import { servedOverrideBaseOf } from './served-override.js';
import type { ObjectStackProtocolImplementation } from './protocol.js';
+/**
+ * [#30062] The types whose packaged base {@link ProtocolDiscoveryAndBasesFacet.packagedArtifactBase}
+ * answers: the artifact item IS the packaged declaration for each (none has an
+ * extension fold, unlike the object).
+ */
+export type PackagedArtifactBaseType = 'dashboard' | 'view' | 'report' | 'page' | 'dataset' | 'app' | 'book';
+
export class ProtocolDiscoveryAndBasesFacet {
/** The body of `ObjectStackProtocolImplementation#getProjectId`; the method's docblock there is its contract. */
static getProjectId(host: Pick): string | undefined {
@@ -247,14 +263,70 @@ export class ProtocolDiscoveryAndBasesFacet {
return host.packagedArtifactBase('report', name);
}
+ /** The body of `ObjectStackProtocolImplementation#getPackagedPageBase`; the method's docblock there is its contract. */
+ static getPackagedPageBase(host: Pick, name: string): unknown {
+ return host.packagedArtifactBase('page', name);
+ }
+
+ /** The body of `ObjectStackProtocolImplementation#getPackagedDatasetBase`; the method's docblock there is its contract. */
+ static getPackagedDatasetBase(host: Pick, name: string): unknown {
+ return host.packagedArtifactBase('dataset', name);
+ }
+
+ /** The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract. */
+ static getPackagedAppBase(host: Pick, name: string): unknown {
+ return host.packagedArtifactBase('app', name);
+ }
+
+ /** The body of `ObjectStackProtocolImplementation#getPackagedBookBase`; the method's docblock there is its contract. */
+ static getPackagedBookBase(host: Pick, name: string): unknown {
+ return host.packagedArtifactBase('book', name);
+ }
+
+ /** The body of `ObjectStackProtocolImplementation#servedOverrideBaseOf`; the method's docblock there is its contract. */
+ static servedOverrideBaseOf(type: string, document: unknown): string | undefined {
+ return servedOverrideBaseOf(type, document);
+ }
+
+ /**
+ * The body of `ObjectStackProtocolImplementation#getPairTranslationBundle`;
+ * the method's docblock there is its contract.
+ *
+ * The rows are the environment's ({@link ObjectStackProtocolImplementation.findEnvironmentRows},
+ * ADR-0131 D6/D7), narrowed to the pair's binding, and each is read into
+ * its locale's data by `readAuthoredTranslationLayer` (`@objectstack/core`),
+ * the function the i18n service's authored layer is built with: the same
+ * conversions, the same locale resolution, the same bookkeeping stripped.
+ * So the bundle is exactly the pair's share of the catalog the deployment
+ * serves, with its package kept.
+ */
+ static async getPairTranslationBundle(host: Pick, basePackageId: string): Promise {
+ if (typeof basePackageId !== 'string' || basePackageId === '') return undefined;
+ const pair = customizationPackageId(basePackageId);
+ if (!pair.ok) return undefined;
+ const layer = await readAuthoredTranslationLayer({
+ find: (table: string, opts: { where?: Record; [key: string]: unknown } = {}) =>
+ host.findEnvironmentRows(table, { ...opts, where: { ...opts.where, package_id: pair.value } }),
+ });
+ // `null` is a read that failed: the layer's own rule is "keep what you
+ // had", and a translation input has nothing to keep, so the caller
+ // gets "no pair translation known" — the answer it reads as before the
+ // option existed (the override's own text), never a 5xx on a read of
+ // the document itself.
+ if (layer === null) return undefined;
+ return Object.keys(layer).length > 0 ? (layer as TranslationBundle) : undefined;
+ }
+
/**
* The one body behind {@link getPackagedDashboardBase},
- * {@link getPackagedViewBase} and {@link getPackagedReportBase}:
+ * {@link getPackagedViewBase}, {@link getPackagedReportBase} and
+ * [#30062] {@link getPackagedPageBase}, {@link getPackagedDatasetBase},
+ * {@link getPackagedAppBase} and {@link getPackagedBookBase}:
* {@link lookupArtifactItem}, and nothing wider, so only an item whose
* `_packageId` marks a genuine code package answers, and an overlay
* hydrated under the plain registry key never does.
*/
- static packagedArtifactBase(host: Pick, type: 'dashboard' | 'view' | 'report', name: string): unknown {
+ static packagedArtifactBase(host: Pick, type: PackagedArtifactBaseType, name: string): unknown {
if (typeof name !== 'string' || name === '') return undefined;
try {
return host.lookupArtifactItem(type, name);
diff --git a/packages/metadata-protocol/src/protocol.pair-translation-bundle.test.ts b/packages/metadata-protocol/src/protocol.pair-translation-bundle.test.ts
new file mode 100644
index 00000000000..bb7a1b4c954
--- /dev/null
+++ b/packages/metadata-protocol/src/protocol.pair-translation-bundle.test.ts
@@ -0,0 +1,110 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#30062, ADR-0146 D2/D3] `getPairTranslationBundle(base)`: the translations
+ * a base's paired `.custom` package ships itself, locale-keyed, for the
+ * translators' `pairBundle` (the first rank of ruling record 6631250894's
+ * label order).
+ *
+ * The deployment catalog cannot answer this: the i18n service deep-merges
+ * every code bundle and every authored `translation` row with the package ids
+ * dropped. So the bundle is read from the pair's own active `translation` rows
+ * by their binding, each read into its locale's data the way the i18n
+ * service's authored layer reads it (`readAuthoredTranslationLayer`).
+ *
+ * The readings on a real boot (the row saved through the door, the served
+ * override, the translator's answer) are pinned in `@objectstack/objectql`'s
+ * `protocol-custom-override-bare-slot-envelope.test.ts`; this file pins the
+ * edges a real boot does not reach.
+ */
+import { describe, expect, it } from 'vitest';
+import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
+import { ObjectStackProtocolImplementation } from './protocol.js';
+
+const BASE = 'com.acme.crm';
+const PAIR = 'com.acme.crm.custom';
+
+const translationRow = (id: string, packageId: string | null, data: Record, extra: Record = {}) => ({
+ id, type: 'translation', name: id, package_id: packageId, organization_id: null, state: 'active',
+ metadata: JSON.stringify({ name: id, ...data }), ...extra,
+});
+
+type Row = Record;
+
+/** Plain equality and `null`; any operator or combinator is refused, so a matcher that ignored one cannot prove anything. */
+function matches(row: Row, where: Record = {}): boolean {
+ for (const [key, want] of Object.entries(where)) {
+ if (want === undefined) continue;
+ if (key.startsWith('$')) throw new Error(`stub engine: unsupported combinator ${key}`);
+ if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`);
+ if ((row[key] ?? null) !== want) return false;
+ }
+ return true;
+}
+
+function protocolOver(rows: Row[], opts: { failRead?: boolean } = {}) {
+ const asked: Row[] = [];
+ const read = async (table: string, q?: { where?: Record; limit?: number }) => {
+ const found = table === 'sys_metadata' ? rows.filter((r) => matches(r, q?.where)) : [];
+ // The caller's bound, after the filter, by presence — as the engine applies it.
+ return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found;
+ };
+ const engine = {
+ registry: {},
+ async find(table: string, q?: { where?: Record; limit?: number }) {
+ asked.push({ table, where: q?.where });
+ if (opts.failRead) throw new Error('store unreachable');
+ return read(table, q);
+ },
+ async findOne(table: string, q: { where: Record }) {
+ assertEngineFindOnePredicate(table, q);
+ return (await read(table, q))[0] ?? null;
+ },
+ };
+ return { protocol: new ObjectStackProtocolImplementation(engine as never, undefined, 'env_test'), asked };
+}
+
+describe('[#30062] getPairTranslationBundle — the pair\'s own translations, by their binding', () => {
+ it('answers the pair\'s active translation rows, locale-keyed, and no other package\'s', async () => {
+ const { protocol, asked } = protocolOver([
+ translationRow('pair_zh', PAIR, { locale: 'zh-CN', pages: { crm_welcome: { label: '新欢迎页' } } }),
+ translationRow('pair_ja', PAIR, { locale: 'ja-JP', apps: { crm: { label: '顧客' } } }),
+ translationRow('base_zh', BASE, { locale: 'zh-CN', pages: { crm_welcome: { label: '欢迎页' } } }),
+ translationRow('loose_zh', null, { locale: 'zh-CN', pages: { crm_welcome: { label: '散行' } } }),
+ translationRow('pair_draft', PAIR, { locale: 'zh-CN', pages: { crm_welcome: { label: '草稿' } } }, { state: 'draft' }),
+ ]);
+ expect(await protocol.getPairTranslationBundle(BASE)).toEqual({
+ 'zh-CN': { pages: { crm_welcome: { label: '新欢迎页' } } },
+ 'ja-JP': { apps: { crm: { label: '顧客' } } },
+ });
+ // Every read names the pair's binding.
+ expect(asked.length).toBeGreaterThan(0);
+ for (const q of asked) expect((q.where as Record).package_id).toBe(PAIR);
+ });
+
+ it('strips the bookkeeping the publish stamps on a row, as the i18n service\'s layer does', async () => {
+ const { protocol } = protocolOver([
+ translationRow('pair_zh', PAIR, { locale: 'zh-CN', _packageId: PAIR, _provenance: 'org', pages: { p: { label: '页' } } }),
+ ]);
+ expect(await protocol.getPairTranslationBundle(BASE)).toEqual({ 'zh-CN': { pages: { p: { label: '页' } } } });
+ });
+
+ it('answers undefined for a pair that stores no translation', async () => {
+ const { protocol } = protocolOver([translationRow('base_zh', BASE, { locale: 'zh-CN', pages: { p: { label: '页' } } })]);
+ expect(await protocol.getPairTranslationBundle(BASE)).toBeUndefined();
+ });
+
+ it('answers undefined, reading nothing, for an id that has no pair', async () => {
+ for (const id of [PAIR, '', 'Not An Id', undefined as unknown as string]) {
+ const { protocol, asked } = protocolOver([translationRow('pair_zh', PAIR, { locale: 'zh-CN', pages: {} })]);
+ expect(await protocol.getPairTranslationBundle(id), String(id)).toBeUndefined();
+ expect(asked, String(id)).toEqual([]);
+ }
+ });
+
+ it('answers undefined, never a throw, when the row read fails', async () => {
+ const { protocol, asked } = protocolOver([], { failRead: true });
+ await expect(protocol.getPairTranslationBundle(BASE)).resolves.toBeUndefined();
+ expect(asked.length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index e24e9696ab0..defe5ca6569 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -644,7 +644,7 @@ import { createData, deleteData, updateData, validateData } from './protocol-dat
// [#29985 codemod] Class head: fields, stored-row conversion, resolvers, registration hooks, authoring gates, mutation pub/sub, overlay index: the members below delegate to its facet, `ProtocolClassHeadFacet`.
import { ProtocolClassHeadFacet } from './protocol-class-head.js';
// [#29985 codemod] Discovery, types, diagnostics, packaged bases, credential carry-forward: the members below delegate to its facet, `ProtocolDiscoveryAndBasesFacet`.
-import { ProtocolDiscoveryAndBasesFacet } from './protocol-discovery-and-bases.js';
+import { ProtocolDiscoveryAndBasesFacet, type PackagedArtifactBaseType } from './protocol-discovery-and-bases.js';
// [#29985 codemod] Data CRUD: the members below delegate to its facet, `ProtocolDataCrudFacet`.
import { ProtocolDataCrudFacet } from './protocol-data-crud.js';
// [#29985 codemod] Global search and the metadata cache: the members below delegate to its facet, `ProtocolSearchAndCacheFacet`.
@@ -3312,8 +3312,108 @@ export class ObjectStackProtocolImplementation implements
return ProtocolDiscoveryAndBasesFacet.getPackagedReportBase(protocolHost(this), name);
}
+ /**
+ * [#30062] The PACKAGED (code-layer) declaration of a PAGE — the page a
+ * code package ships, before a `.custom` pair's declared override replaces
+ * it (ADR-0146 D2). The page twin of {@link getPackagedDashboardBase},
+ * answered the same way, and the fact `translatePage`
+ * (`@objectstack/spec/system`) compares against as its `packagedBase`: the
+ * i18n catalog (`pages.`) is the packaged translation of exactly
+ * this body, so it may only replace a string the served page still carries
+ * unchanged from it (ruling record 6631250894: a translation in the pair,
+ * then the override's own label, then the base's translation).
+ *
+ * Measured before this existed: a pair's override of the CRM's
+ * `crm_welcome` labelled `OVpage` was served, and a `zh-CN` reader got the
+ * base's `欢迎页`, because the translation of the served page had no base to
+ * compare with.
+ *
+ * Shadow-immune for the dashboard's reason: the override is served from its
+ * row, never as the artifact, so the body returned is the base's own. A
+ * page has no extension fold, so the artifact item IS the packaged
+ * declaration. Returns `undefined` for a page no code package ships, an
+ * unknown or empty name, and a registry double without the lookup — "no
+ * packaged baseline", read by the translator as the catalog applying
+ * unchanged.
+ */
+ getPackagedPageBase(name: string): unknown {
+ return ProtocolDiscoveryAndBasesFacet.getPackagedPageBase(protocolHost(this), name);
+ }
+
+ /**
+ * [#30062] The PACKAGED (code-layer) declaration of a DATASET, the fact
+ * `translateDataset` compares each string against (`datasets.`: its
+ * label, description, dimension and measure labels). The dataset twin of
+ * {@link getPackagedPageBase}, answered the same way and for the same
+ * reason: a pair's dataset override kept the base's translated label and
+ * measure labels over its own. Returns `undefined` on the same
+ * uncertainties.
+ */
+ getPackagedDatasetBase(name: string): unknown {
+ return ProtocolDiscoveryAndBasesFacet.getPackagedDatasetBase(protocolHost(this), name);
+ }
+
+ /**
+ * [#30062] The PACKAGED (code-layer) declaration of an APP, the fact
+ * `translateApp` compares each string against (`apps.`: its label,
+ * description and navigation labels, a node judged against the base's node
+ * of the same `id`). The app twin of {@link getPackagedPageBase}, answered
+ * the same way. Returns `undefined` on the same uncertainties.
+ */
+ getPackagedAppBase(name: string): unknown {
+ return ProtocolDiscoveryAndBasesFacet.getPackagedAppBase(protocolHost(this), name);
+ }
+
+ /**
+ * [#30062] The PACKAGED (code-layer) declaration of a BOOK, the fact
+ * `translateBook` compares each string against (`books.`: its label,
+ * description and group labels, a group judged against the base's group of
+ * the same `key`). The book twin of {@link getPackagedPageBase}, answered
+ * the same way. Returns `undefined` on the same uncertainties.
+ */
+ getPackagedBookBase(name: string): unknown {
+ return ProtocolDiscoveryAndBasesFacet.getPackagedBookBase(protocolHost(this), name);
+ }
+
+ /**
+ * [#30062, ADR-0146 D2] The base package a SERVED metadata document is the
+ * declared override of, or `undefined` when it is none — read off the
+ * envelope every read stamps on a pair's declaring row when it serves it as
+ * the base's item (`_packageId` = the base) beside the declaration it was
+ * saved with (`overrides.package`). The serving layer asks it before it
+ * hands a translator the pair's own translations
+ * ({@link getPairTranslationBundle}). An `object` is extended, never
+ * replaced (D10), so it never answers one.
+ */
+ servedOverrideBaseOf(type: string, document: unknown): string | undefined {
+ return ProtocolDiscoveryAndBasesFacet.servedOverrideBaseOf(type, document);
+ }
+
+ /**
+ * [#30062, ADR-0146 D2/D3] The translations a base's paired `.custom`
+ * package ships itself, locale-keyed (`{ [locale]: data }`), for the
+ * translators' `pairBundle` (`@objectstack/spec/system`): the first rank of
+ * the label order on a declared override (ruling record 6631250894 — a
+ * translation in the pair, then the override's own label, then the base's
+ * translation).
+ *
+ * Needed because the catalog the deployment serves cannot say where a
+ * string came from: the i18n service deep-merges every code bundle and
+ * every authored `translation` row, package ids dropped. The pair's own
+ * translations are its active `translation` rows (the pair is an authored
+ * package, created only through {@link ensureCustomizationPackage}, so it
+ * ships no code bundle of its own), read here by their binding.
+ *
+ * `undefined` when the base has no pair id, when the pair stores no
+ * translation, and when the row read fails — "no pair translation known",
+ * which the translators read as they did before the option existed.
+ */
+ getPairTranslationBundle(basePackageId: string): Promise {
+ return ProtocolDiscoveryAndBasesFacet.getPairTranslationBundle(protocolHost(this), basePackageId);
+ }
+
/** Docblock and body: {@link ProtocolDiscoveryAndBasesFacet.packagedArtifactBase} in `protocol-discovery-and-bases.ts` [#29985 codemod]. */
- private packagedArtifactBase(type: 'dashboard' | 'view' | 'report', name: string): unknown {
+ private packagedArtifactBase(type: PackagedArtifactBaseType, name: string): unknown {
// [#29985 codemod] The body moved to `protocol-discovery-and-bases.ts`; it reaches this class through `protocol-host.ts`.
return ProtocolDiscoveryAndBasesFacet.packagedArtifactBase(protocolHost(this), type, name);
}
diff --git a/packages/metadata-protocol/src/served-override.test.ts b/packages/metadata-protocol/src/served-override.test.ts
index e61723182b7..e0c7b8f2350 100644
--- a/packages/metadata-protocol/src/served-override.test.ts
+++ b/packages/metadata-protocol/src/served-override.test.ts
@@ -9,7 +9,7 @@
* row in that one respect.
*/
import { describe, expect, it } from 'vitest';
-import { isServedOverrideRowOf } from './served-override.js';
+import { isServedOverrideRowOf, servedOverrideBaseOf } from './served-override.js';
const BASE = 'com.acme.crm';
const PAIR = 'com.acme.crm.custom';
@@ -81,3 +81,47 @@ describe('[#28409] isServedOverrideRowOf', () => {
expect(isServedOverrideRowOf(row(), 42)).toBe(false);
});
});
+
+/**
+ * [#30062] The read-side reading: which base a SERVED document is the declared
+ * override of. The reads stamp a served override with the base's envelope
+ * (`_packageId` = the base) and keep its declaration, so the two agree exactly
+ * on a document served as the base's item from its pair.
+ */
+describe('[#30062] servedOverrideBaseOf', () => {
+ const served = (extra: Record = {}) => ({ name: 'crm_welcome', label: 'OVpage', _packageId: BASE, overrides: { package: BASE, basedOn: '1.0.0' }, ...extra });
+
+ it('names the base for a served override of every type a declared override replaces', () => {
+ for (const type of ['page', 'app', 'dataset', 'book', 'view', 'dashboard', 'report', 'email_template', 'doc', 'mapping', 'translation']) {
+ expect(servedOverrideBaseOf(type, served()), type).toBe(BASE);
+ }
+ // The plural spelling folds to the same type.
+ expect(servedOverrideBaseOf('pages', served())).toBe(BASE);
+ });
+
+ it('names none for a type that takes no declared override', () => {
+ for (const type of ['object', 'permission', 'position', 'datasource', 'not_a_type']) {
+ expect(servedOverrideBaseOf(type, served()), type).toBeUndefined();
+ }
+ });
+
+ it('names none when the envelope and the declaration disagree, or either is missing', () => {
+ // A pair row served under its own binding: not served as the base's item.
+ expect(servedOverrideBaseOf('page', served({ _packageId: PAIR }))).toBeUndefined();
+ // A package-less row.
+ const { _packageId: _dropped, ...unbound } = served();
+ expect(servedOverrideBaseOf('page', unbound)).toBeUndefined();
+ // The base's own item: no declaration.
+ expect(servedOverrideBaseOf('page', served({ overrides: undefined }))).toBeUndefined();
+ // A declaration naming another package than the envelope.
+ expect(servedOverrideBaseOf('page', served({ overrides: { package: 'com.other' } }))).toBeUndefined();
+ expect(servedOverrideBaseOf('page', served({ overrides: { package: '' }, _packageId: '' }))).toBeUndefined();
+ });
+
+ it('is total over inputs it cannot read', () => {
+ for (const document of [null, undefined, 'page', 42, [served()], served({ overrides: 'com.acme.crm' }), served({ overrides: [BASE] })]) {
+ expect(servedOverrideBaseOf('page', document), JSON.stringify(document)).toBeUndefined();
+ }
+ expect(servedOverrideBaseOf(undefined, served())).toBeUndefined();
+ });
+});
diff --git a/packages/metadata-protocol/src/served-override.ts b/packages/metadata-protocol/src/served-override.ts
index 36f541b2421..97b1fe063d8 100644
--- a/packages/metadata-protocol/src/served-override.ts
+++ b/packages/metadata-protocol/src/served-override.ts
@@ -103,3 +103,38 @@ export function isServedOverrideRowOf(row: ServedOverrideRow | null | undefined,
if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) return false;
return (declaration as { package?: unknown }).package === basePackageId;
}
+
+/**
+ * [#30062] The base package a SERVED document is the declared override of, or
+ * `undefined` — the read-side reading of the answer {@link isServedOverrideRowOf}
+ * gave, never a second judgement of the row.
+ *
+ * Every read that serves a base's name from the pair's declaring row stamps
+ * the document with the overridden base's envelope (`_packageId` =
+ * `overrideOf`, `protocol-meta-item-reads.ts` and the list's slot), and the
+ * body keeps the declaration it was saved with (`overrides.package`). The two
+ * agree exactly on a document the reads served as the base's item from its
+ * pair: a row bound anywhere else keeps its own column's binding
+ * (`withRowBinding`), so a pair row that declares nothing reads the pair, a
+ * package-less row reads no package, and a declaration naming some other
+ * package never matches the envelope it is served under.
+ *
+ * | served document | type | answer |
+ * |:--|:--|:--|
+ * | `_packageId: 'hotcrm'`, `overrides.package: 'hotcrm'` | `page` | `'hotcrm'` |
+ * | the same document | `object` | `undefined` — extended, never replaced (D10) |
+ * | `_packageId: 'hotcrm.custom'`, `overrides.package: 'hotcrm'` | `page` | `undefined` — not served as the base's item |
+ * | `_packageId: 'hotcrm'`, no `overrides` | `page` | `undefined` — the base's own item |
+ * | no `_packageId`, `overrides.package: 'hotcrm'` | `view` | `undefined` — a package-less row |
+ *
+ * Pure, synchronous and total, as {@link isServedOverrideRowOf} is.
+ */
+export function servedOverrideBaseOf(type: unknown, document: unknown): string | undefined {
+ if (typeof type !== 'string' || !isOverridableMetadataType(PLURAL_TO_SINGULAR[type] ?? type)) return undefined;
+ if (document === null || typeof document !== 'object' || Array.isArray(document)) return undefined;
+ const declaration = (document as { overrides?: unknown }).overrides;
+ if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) return undefined;
+ const base = (declaration as { package?: unknown }).package;
+ if (typeof base !== 'string' || base === '') return undefined;
+ return (document as { _packageId?: unknown })._packageId === base ? base : undefined;
+}
diff --git a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
index 2808974d8ff..3b718926aa2 100644
--- a/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
+++ b/packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts
@@ -80,6 +80,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ServiceObject } from '@objectstack/spec/data';
import { ObjectStackProtocolImplementation, resetEnvWritableMetadataTypes } from '@objectstack/metadata-protocol';
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
+import { translateMetadataDocument } from '@objectstack/spec/system';
import { ObjectQL } from './engine.js';
const BASE = 'com.example.showcase';
@@ -600,3 +601,95 @@ async function boot2(environmentId: string | undefined, type: string, name: stri
await publishOverride(booted.protocol, type, name, body(EDITED));
return booted;
}
+
+/**
+ * [#30062 item 3, ruling record 6631250894] The label order on a declared
+ * override — a translation in the pair, then the override's own label, then
+ * the base's translation — needs two inputs only the protocol has, and this
+ * pins both on a real boot (the #29613 harness: the real engine, the boot
+ * hydration, the pair through its ensure door, the override through Studio's
+ * draft save and publish):
+ *
+ * - the PACKAGED base of a page, dataset, app and book
+ * (`getPackagedPageBase`, `getPackagedDatasetBase`, `getPackagedAppBase`,
+ * `getPackagedBookBase`) answers the base's own item while the pair's
+ * override is served, at once and after a restart, with and without a
+ * metadata service — never the override it is compared against;
+ * - the served override names its base (`servedOverrideBaseOf`), the base's
+ * own item names none, and the pair's own published translation is what
+ * `getPairTranslationBundle` answers for that base, without the base's.
+ *
+ * Fed to the spec's translator, the two give the ruled answer for each type.
+ */
+const PAIR_TRANSLATION = 'showcase_custom_zh_cn';
+/** The `zh-CN` text each type's catalog group carries, keyed by the item's name. */
+const zhGroup = (type: string, name: string, label: string): Record => {
+ const group = ({ page: 'pages', app: 'apps', dataset: 'datasets', book: 'books' } as Record)[type];
+ return { [group]: { [name]: { label } } };
+};
+const LABEL_ORDER_TYPES = SEALED_TYPES.filter((t) => ['page', 'app', 'dataset', 'book'].includes(t.type));
+const accessorOf = (type: string): string => `getPackaged${type[0].toUpperCase()}${type.slice(1)}Base`;
+
+describe('[#30062] a declared override\'s label order: the packaged base and the pair\'s own translations, on a real boot', () => {
+ for (const withService of [false, true]) {
+ const shape = withService ? 'with a metadata service (a real boot)' : 'with no metadata service';
+ for (const { type, body } of LABEL_ORDER_TYPES) {
+ const name = body(SHIPPED).name;
+
+ it(`${type}, ${shape}: the packaged base is the base's own item while the override is served, at once and after a restart`, async () => {
+ const stores: Stores = new Map();
+ const first = await bootSealed(stores, withService);
+ expect(reading(first.protocol[accessorOf(type)](name)), `${type} before the override`).toBe(`${SHIPPED}/${BASE}`);
+ await publishOverride(first.protocol, type, name, body(EDITED));
+ expect(reading(first.protocol[accessorOf(type)](name)), `${type} at once`).toBe(`${SHIPPED}/${BASE}`);
+ const restarted = await bootSealed(stores, withService);
+ expect(reading(restarted.protocol[accessorOf(type)](name)), `${type} after a restart`).toBe(`${SHIPPED}/${BASE}`);
+ expect(restarted.protocol[accessorOf(type)]('no_such_item'), `${type}, an unknown name`).toBeUndefined();
+ });
+
+ it(`${type}, ${shape}: the served override names its base, the pair's translation is read by its binding, and the translator answers the ruled order`, async () => {
+ const stores: Stores = new Map();
+ const { protocol } = await bootSealed(stores, withService);
+ // Before the override: the base's own item is no declared override.
+ const shipped = (await protocol.getMetaItem({ type, name }))?.item;
+ expect(protocol.servedOverrideBaseOf(type, shipped), `${type}, the base's own item`).toBeUndefined();
+
+ await publishOverride(protocol, type, name, body(EDITED));
+ const served = (await protocol.getMetaItem({ type, name }))?.item;
+ const listed = ((await protocol.getMetaItems({ type }))?.items ?? []).find((i: any) => i?.name === name);
+ expect(protocol.servedOverrideBaseOf(type, served), `${type}, by name`).toBe(BASE);
+ expect(protocol.servedOverrideBaseOf(type, listed), `${type}, listed`).toBe(BASE);
+ // The pair has no translation yet.
+ expect(await protocol.getPairTranslationBundle(BASE), `${type}, a pair with no translation`).toBeUndefined();
+
+ // The pair's own translation, through the same doors; and a package-less one of the
+ // same key, which is in the deployment catalog but is not the pair's. The layer
+ // merges rows in name order, so the package-less row is named to sort AFTER the
+ // pair's: were it read, its text would overwrite the pair's and this pin would see it.
+ const saved = await protocol.saveMetaItem({
+ type: 'translation', name: PAIR_TRANSLATION, packageId: PAIR,
+ item: { name: PAIR_TRANSLATION, locale: 'zh-CN', ...zhGroup(type, name, `${EDITED} (zh)`) },
+ });
+ expect(saved?.success, 'the pair translation save').toBe(true);
+ stores.get('sys_metadata')!.set('zz_legacy_zh', {
+ id: 'zz_legacy_zh', type: 'translation', name: 'zz_legacy_zh_cn', organization_id: null, package_id: null,
+ state: 'active', version: 1, metadata: JSON.stringify({ name: 'zz_legacy_zh_cn', locale: 'zh-CN', ...zhGroup(type, name, 'not the pair') }),
+ });
+ const pairBundle = await protocol.getPairTranslationBundle(BASE);
+ expect(pairBundle, `${type}, the pair's bundle`).toEqual({ 'zh-CN': zhGroup(type, name, `${EDITED} (zh)`) });
+ expect(await protocol.getPairTranslationBundle(PAIR), 'a pair id has no pair').toBeUndefined();
+
+ const catalog = { 'zh-CN': zhGroup(type, name, '基包译文') };
+ const translate = (pair: unknown) => (translateMetadataDocument(type, served, catalog as any, {
+ locale: 'zh-CN', packagedBase: protocol[accessorOf(type)](name), ...(pair ? { pairBundle: pair as any } : {}),
+ }) as any)?.label;
+ expect(translate(pairBundle), `${type}: the pair's translation first`).toBe(`${EDITED} (zh)`);
+ expect(translate(undefined), `${type}: then the override's own label`).toBe(EDITED);
+ const base = (translateMetadataDocument(type, shipped, catalog as any, {
+ locale: 'zh-CN', packagedBase: protocol[accessorOf(type)](name),
+ }) as any)?.label;
+ expect(base, `${type}: the base's own item keeps the base's translation`).toBe('基包译文');
+ });
+ }
+ }
+});
diff --git a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts
index 9670f6addf6..cb6ed5fb721 100644
--- a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts
+++ b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts
@@ -295,6 +295,11 @@ describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolve
getPackagedDashboardBase: vi.fn((name: string) => ({ kind: 'dashboard', name })),
getPackagedViewBase: vi.fn((name: string) => ({ kind: 'view', name })),
getPackagedReportBase: vi.fn((name: string) => ({ kind: 'report', name })),
+ // [#30062] A pair's declared override of a page, dataset, app or book replaces the base's item too.
+ getPackagedPageBase: vi.fn((name: string) => ({ kind: 'page', name })),
+ getPackagedDatasetBase: vi.fn((name: string) => ({ kind: 'dataset', name })),
+ getPackagedAppBase: vi.fn((name: string) => ({ kind: 'app', name })),
+ getPackagedBookBase: vi.fn((name: string) => ({ kind: 'book', name })),
});
it('asks each type its own accessor, handing the served name through unchanged', () => {
@@ -309,17 +314,24 @@ describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolve
expect(p.getPackagedDashboardBase).toHaveBeenCalledTimes(1);
expect(p.getPackagedViewBase).toHaveBeenCalledTimes(1);
expect(p.getPackagedReportBase).toHaveBeenCalledTimes(1);
+ // [#30062] The four sealed types a pair's declared override replaces.
+ expect(packagedObjectBaseOf(p, 'page', 'crm_welcome')).toEqual({ kind: 'page', name: 'crm_welcome' });
+ expect(packagedObjectBaseOf(p, 'dataset', 'opportunity_metrics')).toEqual({ kind: 'dataset', name: 'opportunity_metrics' });
+ expect(packagedObjectBaseOf(p, 'app', 'crm')).toEqual({ kind: 'app', name: 'crm' });
+ expect(packagedObjectBaseOf(p, 'book', 'crm_manual')).toEqual({ kind: 'book', name: 'crm_manual' });
+ for (const accessor of ['getPackagedPageBase', 'getPackagedDatasetBase', 'getPackagedAppBase', 'getPackagedBookBase'] as const) {
+ expect(p[accessor], accessor).toHaveBeenCalledTimes(1);
+ }
});
it('asks nothing for a type with no packaged base', () => {
const p = protocol();
- for (const type of ['action', 'app', 'dataset', 'page', 'toString', 'constructor']) {
+ for (const type of ['action', 'flow', 'pages', 'toString', 'constructor']) {
expect(packagedObjectBaseOf(p, type, 'x'), type).toBeUndefined();
}
- expect(p.getPackagedObjectBase).not.toHaveBeenCalled();
- expect(p.getPackagedDashboardBase).not.toHaveBeenCalled();
- expect(p.getPackagedViewBase).not.toHaveBeenCalled();
- expect(p.getPackagedReportBase).not.toHaveBeenCalled();
+ for (const accessor of Object.keys(p) as (keyof typeof p)[]) {
+ expect(p[accessor], accessor).not.toHaveBeenCalled();
+ }
});
it('answers undefined for an empty name, a protocol without the accessor, and an accessor that throws', () => {
@@ -416,3 +428,257 @@ describe('#25788 §4 report — a published overlay on a packaged report is what
expect((await host[read]('report', REPORT, locale))?.label).toBe(locale === 'zh-CN' ? '各阶段商机' : SHIPPED_REPORT_LABEL);
});
});
+
+// ---------------------------------------------------------------------------
+// §5 — [#30062] a `.custom` pair's declared override: the ruled label order
+// ---------------------------------------------------------------------------
+
+/**
+ * [#30062 item 3, ruling record 6631250894] A pair's declared override
+ * (ADR-0146 D2) is served with ONE label order on every type and every read: a
+ * translation the pair ships, then the override's own label, then the base's
+ * translation. The order itself is `@objectstack/spec/system`'s
+ * (`labelCatalogs`); what is pinned here is that the `/meta` reads hand the
+ * translators both inputs it needs:
+ *
+ * - `packagedBase` — the BASE's item, for a page, a dataset, an app and a
+ * book too (`getPackagedPageBase`, `getPackagedDatasetBase`,
+ * `getPackagedAppBase`, `getPackagedBookBase`), as the object, dashboard,
+ * view and report already had it;
+ * - `pairBundle` — the translations the pair ships itself
+ * (`servedOverrideBaseOf` + `getPairTranslationBundle`), for every served
+ * declared override.
+ *
+ * Measured before (a booted CRM in zh-CN, the card's table): a page override
+ * labelled `OVpage` served the base's `欢迎页`, a dataset override served the
+ * base's text. And a view override whose pair ships a `zh-CN` translation of its
+ * new label served the override's English: the pair's translation never got in.
+ *
+ * The rows are seeded the way Studio's Customize publishes them: the override
+ * row bound to the pair (`package_id: .custom`) declaring
+ * `overrides: { package: }`, and the pair's translation an active
+ * `translation` row bound to the pair. The deployment catalog is what the i18n
+ * service serves: every code bundle deep-merged with every authored row
+ * (`readAuthoredTranslationLayer`), so the pair's row is in it too, with its
+ * package dropped.
+ */
+
+const PAIR_PKG = `${SHOWCASE_PKG}.custom`;
+const OVERRIDE_DECLARATION = { package: SHOWCASE_PKG, basedOn: '1.0.0' };
+
+interface PrecedenceType {
+ readonly type: 'page' | 'dataset' | 'app' | 'book' | 'view';
+ readonly name: string;
+ /** The base's item, as the code package ships it. */
+ readonly shipped: () => Record;
+ /** The deployment catalog's group for this item, keyed by the item's label. */
+ readonly catalog: (label: string) => Record;
+ readonly shippedZh: string;
+}
+
+const PRECEDENCE_TYPES: readonly PrecedenceType[] = [
+ {
+ type: 'page', name: 'crm_welcome', shippedZh: '欢迎页',
+ shipped: () => ({ name: 'crm_welcome', label: 'Welcome', type: 'app', regions: [] }),
+ catalog: (label) => ({ pages: { crm_welcome: { label } } }),
+ },
+ {
+ type: 'dataset', name: 'opportunity_metrics', shippedZh: '商机指标',
+ shipped: () => ({
+ name: 'opportunity_metrics', label: 'Opportunity Metrics', object: OBJ,
+ dimensions: [{ name: 'status', label: 'Status', field: 'status', type: 'string' }],
+ measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }],
+ }),
+ catalog: (label) => ({ datasets: { opportunity_metrics: { label } } }),
+ },
+ {
+ type: 'app', name: 'crm', shippedZh: '客户关系',
+ shipped: () => ({ name: 'crm', label: 'CRM' }),
+ catalog: (label) => ({ apps: { crm: { label } } }),
+ },
+ {
+ type: 'book', name: 'crm_manual', shippedZh: '手册',
+ shipped: () => ({ name: 'crm_manual', label: 'Manual', groups: [{ key: 'start', label: 'Start', pages: [] }] }),
+ catalog: (label) => ({ books: { crm_manual: { label } } }),
+ },
+ // An existing type, for the pair's rank: the base was always handed over, the pair's translation never.
+ {
+ type: 'view', name: VIEW, shippedZh: '进行中',
+ shipped: () => ({ ...packagedViewItem(VIEW) }),
+ catalog: (label) => ({ objects: { [OBJ]: { _views: { in_progress: { label } } } } }),
+ },
+];
+
+/** Deep merge, `top` over `under`, of two translation-data groups (the shape `deepMerge` gives). */
+const mergeData = (under: Record, top: Record): Record => {
+ const out: Record = { ...under };
+ for (const [k, v] of Object.entries(top)) {
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object'
+ ? mergeData(out[k], v)
+ : v;
+ }
+ return out;
+};
+
+/** The deployment's code catalog: `en` repeats each shipped label, `zh-CN` translates it. */
+const precedenceStaticCatalog = (): Record> => {
+ let en: Record = {};
+ let zh: Record = {};
+ for (const t of PRECEDENCE_TYPES) {
+ en = mergeData(en, t.catalog(String(t.shipped().label)));
+ zh = mergeData(zh, t.catalog(t.shippedZh));
+ }
+ return { en, 'zh-CN': zh };
+};
+
+interface PrecedenceSeed {
+ /** The override's label per type; absent = no override row. */
+ readonly overrides?: Partial>;
+ /** The pair's own `zh-CN` translation of the item's label, per type. */
+ readonly pairZh?: Partial>;
+}
+
+function makePrecedenceHost(seed: PrecedenceSeed, who: Who) {
+ const registry = new SchemaRegistry({ multiTenant: false });
+ registry.logLevel = 'silent';
+ registry.registerItem('view', { name: OBJ, ...taskContainer() }, 'name', SHOWCASE_PKG);
+ for (const item of expandViewContainer(OBJ, taskContainer())) registry.registerItem('view', item, 'name', SHOWCASE_PKG);
+ for (const t of PRECEDENCE_TYPES) {
+ if (t.type !== 'view') registry.registerItem(t.type, t.shipped(), 'name', SHOWCASE_PKG);
+ }
+
+ const rows: Record[] = [];
+ for (const t of PRECEDENCE_TYPES) {
+ const label = seed.overrides?.[t.type];
+ if (label === undefined) continue;
+ rows.push({
+ id: `ov_${t.type}`, type: t.type, name: t.name, package_id: PAIR_PKG, organization_id: null, state: 'active',
+ metadata: JSON.stringify({ ...t.shipped(), label, overrides: OVERRIDE_DECLARATION }),
+ });
+ }
+ let pairZh: Record = {};
+ for (const t of PRECEDENCE_TYPES) {
+ const label = seed.pairZh?.[t.type];
+ if (label !== undefined) pairZh = mergeData(pairZh, t.catalog(label));
+ }
+ if (Object.keys(pairZh).length > 0) {
+ rows.push({
+ id: 'tr_pair_zh', type: 'translation', name: 'showcase_custom_zh', package_id: PAIR_PKG, organization_id: null,
+ state: 'active', metadata: JSON.stringify({ name: 'showcase_custom_zh', locale: 'zh-CN', ...pairZh }),
+ });
+ }
+
+ // What the i18n service serves: the code catalog with every authored row merged over it.
+ const catalog = precedenceStaticCatalog();
+ const merged: Record = { en: catalog.en, 'zh-CN': mergeData(catalog['zh-CN'], pairZh) };
+ const i18n = {
+ getLocales: () => ['en', 'zh-CN'],
+ getTranslations: (locale: string) => merged[locale],
+ getDefaultLocale: () => 'en',
+ };
+
+ const matches = (r: Record, w: Record) =>
+ Object.entries(w ?? {}).every(([k, v]) => {
+ if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
+ return whereValueMatches(r[k] ?? null, v ?? null);
+ });
+ const engine = {
+ registry,
+ find: async (table: string, q: { where: Record; limit?: number }) => {
+ if (table !== 'sys_metadata') return [];
+ const matched = rows.filter((r) => matches(r, q.where));
+ return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched;
+ },
+ findOne: async (table: string, q: { where: Record }) => {
+ assertEngineFindOnePredicate(table, q);
+ return table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) ?? null : null;
+ },
+ };
+ const protocol = new ObjectStackProtocolImplementation(engine as never, undefined, 'env_test');
+ const rest = new RestServer(
+ createMockServer() as never, protocol as never, { api: { requireAuth: false } } as never,
+ undefined, undefined, undefined, undefined, undefined,
+ undefined, undefined, undefined, undefined, undefined,
+ async () => i18n as never,
+ );
+ (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = async () => ({ ...CALLERS[who] });
+ rest.registerRoutes();
+ const routes = rest.getRouteManager();
+ const run = async (path: string, params: Record, locale: string) => {
+ const entry = routes.get('GET', path);
+ if (!entry) throw new Error(`route not registered: GET ${path}`);
+ let body: unknown;
+ let status = 200;
+ const res = {
+ status: (s: number) => { status = s; return res; },
+ header: () => res,
+ setHeader: () => res,
+ json: (b: unknown) => { body = b; },
+ send: (b: unknown) => { body = b; },
+ } as unknown as Parameters[1];
+ await entry.handler(
+ { params, query: {}, body: {}, headers: { 'accept-language': locale }, method: 'GET', path } as unknown as Parameters[0],
+ res,
+ );
+ expect(status, `GET ${path} ${JSON.stringify(params)}: ${JSON.stringify(body)}`).toBe(200);
+ return body as any;
+ };
+ return {
+ item: async (type: string, name: string, locale: string) =>
+ (await run('/api/v1/meta/:type/:name', { type, name }, locale))?.item,
+ listed: async (type: string, name: string, locale: string) => {
+ const body = await run('/api/v1/meta/:type', { type }, locale);
+ const items: any[] = Array.isArray(body) ? body : body?.items ?? [];
+ return items.find((d) => d?.name === name);
+ },
+ /** `GET /meta/book/:name/tree` — the docs sidebar's book. */
+ bookTree: async (name: string, locale: string) => run('/api/v1/meta/book/:name/tree', { name }, locale),
+ };
+}
+
+const precedenceCells = () => PRECEDENCE_TYPES.flatMap((t) => READS.map((read) => ({ t, type: t.type, read })));
+const OV = (type: string) => `OV ${type} (30062)`;
+const PAIR_ZH = (type: string) => `配对包译文 ${type}`;
+
+describe('[#30062] §5 a .custom pair\'s declared override serves the ruled label order on both /meta reads', () => {
+ it.each(precedenceCells())('$type, $read read: the override\'s own label beats the base\'s translation (no pair translation)', async ({ t, read }) => {
+ const host = makePrecedenceHost({ overrides: { [t.type]: OV(t.type) } }, 'admin');
+ expect((await host[read](t.type, t.name, 'zh-CN'))?.label, 'zh-CN').toBe(OV(t.type));
+ expect((await host[read](t.type, t.name, 'en'))?.label, 'en').toBe(OV(t.type));
+ });
+
+ it.each(precedenceCells())('$type, $read read: a translation the pair ships beats the override\'s own label', async ({ t, read }) => {
+ const host = makePrecedenceHost({ overrides: { [t.type]: OV(t.type) }, pairZh: { [t.type]: PAIR_ZH(t.type) } }, 'admin');
+ expect((await host[read](t.type, t.name, 'zh-CN'))?.label, 'zh-CN').toBe(PAIR_ZH(t.type));
+ // The pair ships no `en`: the override's own label.
+ expect((await host[read](t.type, t.name, 'en'))?.label, 'en').toBe(OV(t.type));
+ });
+
+ it.each(precedenceCells())('$type, $read read: with no override the base\'s item reads its translation', async ({ t, read }) => {
+ const host = makePrecedenceHost({}, 'admin');
+ expect((await host[read](t.type, t.name, 'zh-CN'))?.label, 'zh-CN').toBe(t.shippedZh);
+ expect((await host[read](t.type, t.name, 'en'))?.label, 'en').toBe(t.shipped().label);
+ });
+
+ it('a member reads the same order as an admin', async () => {
+ const host = makePrecedenceHost({ overrides: { page: OV('page'), dataset: OV('dataset') }, pairZh: { page: PAIR_ZH('page') } }, 'member');
+ for (const read of READS) {
+ expect((await host[read]('page', 'crm_welcome', 'zh-CN'))?.label, `page ${read}`).toBe(PAIR_ZH('page'));
+ expect((await host[read]('dataset', 'opportunity_metrics', 'zh-CN'))?.label, `dataset ${read}`).toBe(OV('dataset'));
+ }
+ });
+
+ it('the book tree serves the label the book list serves (#26690: the docs sidebar and the list never disagree)', async () => {
+ for (const seed of [
+ { overrides: { book: OV('book') } },
+ { overrides: { book: OV('book') }, pairZh: { book: PAIR_ZH('book') } },
+ {},
+ ] as PrecedenceSeed[]) {
+ const host = makePrecedenceHost(seed, 'admin');
+ for (const locale of LOCALES) {
+ const listed = (await host.listed('book', 'crm_manual', locale))?.label;
+ expect((await host.bookTree('crm_manual', locale))?.label, `${JSON.stringify(seed)} ${locale}`).toBe(listed);
+ }
+ }
+ });
+});
diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts
index 70819ac7bd0..91c49720146 100644
--- a/packages/rest/src/meta-item-read-gate.ts
+++ b/packages/rest/src/meta-item-read-gate.ts
@@ -2941,12 +2941,19 @@ export function metaTranslateOptions(
* serves (`getPackagedDashboardBase`, `getPackagedViewBase`,
* `getPackagedReportBase`). A view is looked up by the served item's
* registry name, the qualified `