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 `.` — never the bare key - * the catalog addresses it by under its object. - * - * A translatable type without a row (`action`, `app`, `dataset`, `page`) is - * ADR-0126 tier B — a write against its packaged item answers - * `NOT_OVERRIDABLE`, so no org overlay of a packaged item exists for it — - * and the protocol has no packaged-base accessor for it. + * the catalog addresses it by under its object; + * - [#30062] `page`, `dataset`, `app` and `book` — sealed (no org overlay, + * ADR-0131 D6), but a `.custom` pair's declared override replaces the + * base's item on every read (ADR-0146 D2), so the served document can + * differ from the one the catalog translates (`getPackagedPageBase`, + * `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`). + * The label order the base feeds is ruling record 6631250894's: a + * translation in the pair ({@link pairTranslationBundleOf}), then the + * override's own label, then the base's translation. + * + * The one translatable type without a row, `action`, has no packaged-base + * accessor: its override waits for ADR-0146 D9's conditions, so no read serves + * one over a packaged action. * * ⛔ One table, never a second resolver beside it: a type that gains a * packaged-base accessor on the protocol adds its row here. @@ -2956,6 +2963,10 @@ const PACKAGED_BASE_ACCESSORS: ReadonlyMap = new Map = new MapBase(name)`. * * `undefined` on every uncertainty — a type with no row, an empty name, a * protocol without the accessor (they are server-only extensions, so @@ -2986,13 +2998,62 @@ export function packagedObjectBaseOf(protocol: unknown, type: string, name: unkn } } +/** + * [#30062, ADR-0146 D2] The translations a served document's `.custom` pair + * ships itself, for `translateMetadataDocument`'s `pairBundle` — or + * `undefined` when the document is not a pair's declared override. + * + * Two protocol questions, both the protocol's to answer: + * `servedOverrideBaseOf(type, document)` — is this served document the + * declared override of a base, and of which (read off the envelope the read + * stamped on it); then `getPairTranslationBundle(base)` — that base's pair's + * own `translation` rows, locale-keyed. The deployment catalog cannot answer + * the second: it is every package's translations deep-merged, ids dropped. + * + * `undefined` on every uncertainty, {@link packagedObjectBaseOf}'s rule: a + * protocol without the accessors (server-only, feature-detected), an accessor + * that throws or rejects. The translator reads `undefined` as before the option + * existed: a string the override changed keeps its own text. + * + * `memo` is one read's cache, by base: a list serves many overrides of one + * base, and their pair's translations are read once. + */ +export async function pairTranslationBundleOf( + protocol: unknown, + type: string, + document: unknown, + memo?: Map>, +): Promise { + const p: any = protocol; + if (!p || typeof p.servedOverrideBaseOf !== 'function' || typeof p.getPairTranslationBundle !== 'function') { + return undefined; + } + let base: unknown; + try { + base = p.servedOverrideBaseOf(type, document); + } catch { + return undefined; + } + if (typeof base !== 'string' || base === '') return undefined; + let pending = memo?.get(base); + if (pending === undefined) { + pending = Promise.resolve() + .then(() => p.getPairTranslationBundle(base)) + .catch(() => undefined); + memo?.set(base, pending); + } + return pending; +} + /** How a transport reaches what the list translation reads. */ export interface MetaListTranslationSources { /** This request's i18n service, or `undefined` when the deployment has none. */ resolveI18nService(): Promise; /** * This request's protocol, for the packaged base of an object, dashboard, - * view or report ({@link packagedObjectBaseOf}); `undefined` when unreachable. + * view, report, page, dataset, app or book ({@link packagedObjectBaseOf}) + * and a declared override's pair translations + * ({@link pairTranslationBundleOf}); `undefined` when unreachable. */ resolveProtocol(): Promise; /** @@ -3038,19 +3099,24 @@ export async function translateMetaList( if (!locale) return items; const { translateMetadataDocument } = await import('@objectstack/spec/system'); // [#8284 · #20730] One protocol resolution for the whole page, for every - // type with a packaged base (object, dashboard, view, report); the lookup - // itself is a synchronous in-memory registry read per element, by that - // element's own registry name — a view's qualified `.`. + // type with a packaged base (object, dashboard, view, report, and since + // #30062 page, dataset, app, book); the lookup itself is a synchronous + // in-memory registry read per element, by that element's own registry + // name — a view's qualified `.`. const p = PACKAGED_BASE_ACCESSORS.has(metaType) ? await sources.resolveProtocol() : undefined; + // [#30062] A declared override's pair translations, read once per base for the page. + const pairBundles = new Map>(); // `getMetaItems` elements are metadata documents (the list envelope is the // OUTER `{ type, items }`), so every element translates directly (#5563). - const translated = arr.map((item) => { + const translated = await Promise.all(arr.map(async (item) => { + const pairBundle = p === undefined ? undefined : await pairTranslationBundleOf(p, metaType, item, pairBundles); const out = translateMetadataDocument(metaType, item, bundle, { ...metaTranslateOptions(i18n, locale), packagedBase: packagedObjectBaseOf(p, metaType, item?.name), + ...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}), }); return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out; - }); + })); return Array.isArray(raw) ? translated : { ...raw, items: translated }; } @@ -3066,7 +3132,9 @@ export async function translateMetaList( * built-in fallbacks still apply); no locale is. The i18n service is resolved * only for a translatable type, and the protocol only for a type with a * packaged base — an object, a dashboard, a view or a report (#8284, #20730, #25788: - * {@link packagedObjectBaseOf}). + * {@link packagedObjectBaseOf}), and since #30062 a page, a dataset, an app or a + * book, each also handed its pair's own translations when the document is a + * declared override ({@link pairTranslationBundleOf}). * * Moved here, unchanged, from `RestServer.translateMetaItem` (which now * delegates), so the runtime dispatcher's item read translates with the same @@ -3106,12 +3174,14 @@ export async function translateMetaDocument( } } const { translateMetadataDocument } = await import('@objectstack/spec/system'); - const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType) - ? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name) - : undefined; + const p = PACKAGED_BASE_ACCESSORS.has(metaType) ? await sources.resolveProtocol() : undefined; + const packagedBase = packagedObjectBaseOf(p, metaType, (document as any)?.name); + // [#30062] A declared override's pair translations: the ruled order's first rank. + const pairBundle = p === undefined ? undefined : await pairTranslationBundleOf(p, metaType, document); const translated = translateMetadataDocument(metaType, document as any, bundle, { ...metaTranslateOptions(i18n, locale), packagedBase, + ...(pairBundle !== undefined ? { pairBundle: pairBundle as any } : {}), }); return metaType === 'view' ? withoutInventedViewLabel(document as Record, translated) : translated; } @@ -4194,7 +4264,8 @@ export function createMetaLayeredAnswer( /** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */ export interface MetaBookTreeSources - extends MetaReadGateAudienceSources, Pick { + extends MetaReadGateAudienceSources, Pick, + Partial> { /** * One list read of `type` for the tree, env-wide, scoped to `packageId` when * the request names one (`?package=`, ADR-0048). A THROW propagates: an @@ -4244,8 +4315,10 @@ export type MetaBookTreeAnswer = * group's `label` from the bundle's `books` group — through * {@link translateMetaDocument}, the table every `/meta/book` read * translates through, so the tree and the list the console's sidebar - * draws cannot disagree. Translated AFTER the gate: the audience is - * judged on the book as stored, and a label is not an access input. + * draws cannot disagree ([#30062] its packaged base and its pair's + * translations included, over the transport's `resolveProtocol`). + * Translated AFTER the gate: the audience is judged on the book as + * stored, and a label is not an access input. * 6. The tree, its ENTRIES narrowed by each doc's effective audience, so a * reader never sees an entry that would refuse them on fetch. */ @@ -4277,7 +4350,14 @@ export async function createMetaBookTreeAnswer( tags: d.tags, packageId: d._packageId, })); - // A book has no packaged base (`PACKAGED_BASE_ACCESSORS`), so the protocol is never asked for. - const served = await translateMetaDocument({ ...sources, resolveProtocol: async () => undefined }, 'book', book); + // [#30062] The book's packaged base and its pair's translations, as the + // `/meta/book` list the sidebar draws reads them, so the two never disagree + // on a pair's override. A transport that supplies no protocol gets the + // catalog alone, as before. + const served = await translateMetaDocument( + { ...sources, resolveProtocol: sources.resolveProtocol ?? (async () => undefined) }, + 'book', + book, + ); return { ok: true, tree: audience.readableTree(served as typeof book, docs) }; } diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index 0b388895d55..8f245a79993 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -416,6 +416,8 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M requestLocale: (i18n) => this.extractLocale(req, i18n), // [#26690] The book's own labels, from the bundle's `books` group. resolveI18nService: () => this.resolveI18nService(environmentId, req), + // [#30062] …judged against its packaged base, with its pair's translations. + resolveProtocol: async () => prot, }, { name: req.params.name, packageId }, ); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index ecebfaf2009..7618ad7478e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1506,6 +1506,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.pair-translation-bundle.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.permission-set-name-cross-package.test.ts", "verb": "delete", @@ -4519,7 +4524,7 @@ { "file": "packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts", "verb": "findOne", - "pinned": 1 + "pinned": 2 }, { "file": "packages/rest/src/meta-disabled-package-doors.test.ts",