Skip to content
Merged
14 changes: 14 additions & 0 deletions .changeset/30062-declared-override-label-order.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 13 additions & 1 deletion content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>.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,
Expand Down
76 changes: 74 additions & 2 deletions packages/metadata-protocol/src/protocol-discovery-and-bases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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,
Expand All @@ -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<ProtocolHost, 'environmentId'>): string | undefined {
Expand Down Expand Up @@ -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<ProtocolHost, 'packagedArtifactBase'>, 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<ProtocolHost, 'packagedArtifactBase'>, 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<ProtocolHost, 'packagedArtifactBase'>, 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<ProtocolHost, 'packagedArtifactBase'>, 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<ProtocolHost, 'findEnvironmentRows'>, basePackageId: string): Promise<TranslationBundle | undefined> {
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<string, unknown>; [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<ProtocolHost, 'lookupArtifactItem'>, type: 'dashboard' | 'view' | 'report', name: string): unknown {
static packagedArtifactBase(host: Pick<ProtocolHost, 'lookupArtifactItem'>, type: PackagedArtifactBaseType, name: string): unknown {
if (typeof name !== 'string' || name === '') return undefined;
try {
return host.lookupArtifactItem(type, name);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) => ({
id, type: 'translation', name: id, package_id: packageId, organization_id: null, state: 'active',
metadata: JSON.stringify({ name: id, ...data }), ...extra,
});

type Row = Record<string, unknown>;

/** 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<string, unknown> = {}): 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<string, unknown>; 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<string, unknown>; 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<string, unknown> }) {
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<string, unknown>).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);
});
});
Loading
Loading