Skip to content

Commit fdad178

Browse files
fix(metadata-protocol,rest): a .custom pair's declared override is labelled in the ruled order on every /meta read (#30353)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6HN9TS7GFoRJmsKPyPbpwW)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:客户在 `<基础包>.custom` 配对包里覆盖了基础包的页面、数据集、应用或书(仪表盘、视图、报表同样适用),改过的标签现在在所有 `/meta` 读上按裁定顺序显示:配对包自己的译文优先,其次是覆盖项自己写的文字,最后才是基础包的译文。 - 为什么改:以前页面、数据集、应用、书的覆盖项在非源语言下被基础包的译文盖住,客户改的名字看不到;七类覆盖项都不读配对包自己发布的译文。 - 风险与回滚:只有声明覆盖项多一次读,读的是配对包的 active translation 行,列表按基础包每次请求只读一次;不是覆盖项的条目没有新增读。回滚即撤本 PR,没有数据迁移。 - 先看:`packages/rest/src/meta-item-read-gate.ts` 的 `pairTranslationBundleOf` 和 `PACKAGED_BASE_ACCESSORS` 新加的四行,再看 `packages/metadata-protocol/src/protocol-discovery-and-bases.ts` 的 `getPairTranslationBundle`。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/meta-dashboard-view-i18n-explicit-override.test.ts`,预期 108 条全绿;修复前的代码上有 19 条是红的。 - 需要你做的:无,席位落地。 Fixes #30062 Clause-②: yes (widening) — `ObjectStackProtocolImplementation` gains six public methods, `getPackagedPageBase`, `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`, `servedOverrideBaseOf` and `getPairTranslationBundle` (measured on the built `packages/metadata-protocol/dist/index.d.ts`); `@objectstack/rest`'s exported `MetaBookTreeSources` accepts one more optional key, `resolveProtocol` (measured on the built `packages/rest/dist/index.d.ts`). REST's new `pairTranslationBundleOf` is module-internal: it is not in the package's export list. Nothing accepted is taken away. Metadata-protocol: semantic Skills-docs: none — the "Customizing an installed package (ADR-0146)" paragraph of `skills/objectstack-platform/SKILL.md` says nothing about how an override's labels are translated, so no sentence there turns false; its stale served-type list is #30229's. ## What changes The spec half (PR #30236, #30193) gave every document translator `packagedBase` and `pairBundle`. Its "Server-half contract for #30062", quoted: > For a served item that is a declared `.custom` override, on item, list and container reads alike, for all eight types: > > - `packagedBase` = the BASE package's item (pre-override: the document the override replaces), never the override itself; > - `pairBundle` = `{ locale: data }` built from that `.custom` package's own translations only: its code-shipped translation bundle and its authored `translation` rows, both of which are the pair's own; > - keep passing `packagedBase` for object, dashboard, view and report exactly as today, on every served item (`PACKAGED_BASE_ACCESSORS`, `form-routes.ts` :385 :421): the object reads it for extension-folded field labels too (ADR-0029 D9.2a, #27559), so it is not override-only. Add the same accessor for page, dataset, app and book; > - omit `pairBundle` for an item that is not a declared override. This PR is that server half, with the seat's rulings on where the contract does not fit the code (Deviations below). **`packagedBase` for page, dataset, app and book.** Four protocol accessors, `getPackagedPageBase` / `getPackagedDatasetBase` / `getPackagedAppBase` / `getPackagedBookBase`, answer the base package's own item through the same shadow-immune lookup `getPackagedDashboardBase` / `ViewBase` / `ReportBase` use (`packagedArtifactBase`, now typed over the seven artifact types). They answer the base's item while the override is served, right after it is published and after a restart, and `undefined` for an unknown name. `PACKAGED_BASE_ACCESSORS` in the REST read gate gains the four rows, so `GET /meta/:type/:name` and `GET /meta/:type` hand them to the translators. `action` is now the one translatable type without a row. **`pairBundle` for a declared override.** `servedOverrideBaseOf(type, document)` names the base a served document overrides: an overridable type, an `overrides.package`, and a served `_packageId` equal to it (the envelope `protocol-meta-item-reads.ts` stamps on a served override). Anything else, a base's own item included, answers `undefined`. `getPairTranslationBundle(base)` reads that base's pair's own active `translation` rows (`package_id` = `customizationPackageId(base)`) through `readAuthoredTranslationLayer`, the same reader the i18n service's authored layer uses (conversions, locale resolution and bookkeeping stripped), and answers `{ locale: data }`, or `undefined` when the pair has none, the id is empty or invalid, or the read fails. The REST gate's `pairTranslationBundleOf` joins the two, feature-detected on the protocol, and passes the result as `pairBundle` on the item read and on every listed item. A list reads each base's pair once per request. **The book tree.** `createMetaBookTreeAnswer` forced `resolveProtocol` to `undefined`, so `GET /meta/book/:name/tree` translated a book with no packaged base. `MetaBookTreeSources` now takes the transport's `resolveProtocol`, and the RestServer route passes it. The runtime dispatcher's tree sources already spread it. The docs sidebar and the book list now serve the same label (#26690). ## Deviations - **`packages/rest/src/meta-item-read-routes.ts`** (+2): the book tree route's sources, outside the dispatched surface; widened by the seat for the tree fix above. - **`packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts`** (+93, test only): the real-boot pins reuse #29613's sealed-boot fixture there. They cannot live in `packages/metadata-protocol`: it does not depend on `@objectstack/objectql` (objectql depends on it), so the import would be a cycle. Widened by the seat. - **`scripts/engine-double-contract.pinned.json`** (+5 / -1, generated): `check:engine-double-contract` ratchets the two new `findOne` doubles the new pins assert (`protocol.pair-translation-bundle.test.ts` 0 → 1, `meta-dashboard-view-i18n-explicit-override.test.ts` 1 → 2), written by `node scripts/check-engine-double-contract.mjs --write`. Coverage grew; nothing else in the ledger moved. - **No code translation source.** The contract names the pair's "code-shipped translation bundle". A `.custom` package is authored, never a code package, so it has none; and code bundles reach the i18n service's static layer with no package provenance. `pairBundle` is built from the pair's active `translation` rows only (seat ruling). - **Seven types, not eight.** An object is extended, never replaced (ADR-0146 D10): it carries no declared override, so `servedOverrideBaseOf` answers `undefined` for it and no object read takes a `pairBundle`. Its `packagedBase` is passed exactly as before. - **Not covered here.** The view container read builds its slots without the served-override envelope, so `servedOverrideBaseOf` answers `undefined` there and the slots keep today's `packagedBase` order. `form-routes.ts` (public form views) is outside the surface and is unchanged: it still passes `packagedBase`, with no `pairBundle`. - **A package-less authored translation** is in the deployment catalog but is not the pair's, so it no longer outranks a changed label on a declared override. That is the ruled order (6631250894: only the pair's translation ranks first). ## Tests - `packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts` (real `SchemaRegistry` and protocol over fake engine rows, through `RestServer`): §1 now asks each of the eight accessor rows, the four new ones included, and asks no accessor for `action`, `flow`, a plural and a prototype key. New §5 over page, dataset, app, book and view: an override's own label beats the base's translation (`zh-CN` and `en`); a translation the pair ships wins in `zh-CN` while `en` reads the override's label, on the item and the list read; no override reads the base's translation; a member reads what an admin reads; the book tree serves the label the book list serves for three seeds. 19 red on the pre-fix code, 108/108 after. - `packages/objectql/src/protocol-custom-override-bare-slot-envelope.test.ts`: a real boot, page / app / dataset / book, with and without a metadata service: each accessor answers the base's item before the override, right after it and after a restart; `servedOverrideBaseOf` answers the base on the served and listed items and nothing on the base's own; `getPairTranslationBundle` is `undefined` before, then exactly the pair's saved translation beside a package-less row of the same key, and `undefined` for the pair's own id; fed to `translateMetadataDocument` the three give pair → own → base. 16 new, file 87/87. - `packages/metadata-protocol/src/served-override.test.ts` (`servedOverrideBaseOf` table) and the new `protocol.pair-translation-bundle.test.ts` (pair rows only, every read naming `package_id`, bookkeeping stripped, `undefined` with no read for an empty, invalid or pair id, `undefined` on a read failure): 15/15. Its fake engine refuses any operator or combinator, holds the caller's `limit` after the filter, and opens `findOne` with `assertEngineFindOnePredicate`. ## Ablations Through `scripts/ablation-replace.mjs`, each restored to blob == HEAD with `git diff HEAD` empty. A metadata-protocol mutation was rebuilt and proved in `dist/` with `ablation-dist-preflight.mjs` (exit 0) before the objectql and rest legs, which read its `dist/`; after the last one a clean rebuild was proved free of all three markers (`--absent`, exit 0). | | Mutation | Red | |:--|:--|:--| | A1 | the four new `PACKAGED_BASE_ACCESSORS` rows deleted | rest 18/108 | | A2 | `pairTranslationBundleOf` never fetches | rest 11/108 | | A3 | the book tree route's `resolveProtocol` deleted | rest 1/108 (tree == list) | | A4 | `servedOverrideBaseOf` always `undefined` | mp 1/15, objectql 8/87, rest 11/108 | | A5 | the pair's `package_id` filter dropped | mp 2/15, objectql 8/87 | | A6 | `getPackagedBookBase` answers `undefined` | objectql 4/87, rest 4/108 | A5's first run left objectql green: the layer merges rows in name order, and the package-less row sorted before the pair's, so its text never showed. The row was renamed to sort after the pair's, and the pin now goes red. ## Docs `content/docs/ui/translations.mdx`, "An edit beats the packaged catalog": the bullet "An edited string is served in the language it was written in, to every reader" is false for a declared override whose pair translates the string. It now names that exception, and a new paragraph states the order for a declared override (page, dataset, app, book, dashboard, view, report; the pair's translations first; only `translation` items saved in the pair; the item, list and book tree reads agree). `content/docs/concepts/metadata-lifecycle.mdx` and `content/docs/api/metadata-api.mdx` were read sentence by sentence: neither says how an override's labels are translated, so neither changes. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CajcU31aZRQuQzwWj6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f672e0d commit fdad178

12 files changed

Lines changed: 868 additions & 35 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/rest': minor
4+
---
5+
6+
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)
7+
8+
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.
9+
Metadata-protocol: semantic
10+
11+
- **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.
12+
- **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.
13+
- **Object.** An object is extended, never replaced (ADR-0146 D10), so it has no declared override and no pair bundle: seven types take one.
14+
- **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).

‎content/docs/ui/translations.mdx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,21 @@ with the same string in the item as the package shipped it:
217217
- **A widget, global filter or bulk action the package never shipped counts as
218218
edited**: the org authored it, so its text is served as written.
219219
- **An edited string is served in the language it was written in, to every
220-
reader.** The bundle entry for that string no longer applies to it.
220+
reader**, unless the base's paired `.custom` package translates it (below).
221+
The bundle entry for that string no longer applies to it.
221222
- **Resetting the overlay restores the shipped string and its translation.**
222223

224+
A declared override in the base's paired `<base>.custom` package
225+
([ADR-0146](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146))
226+
is compared with the base's own item the same way, for a page, dataset, app
227+
and book as well as a dashboard, view and report, and the pair's own
228+
translations rank first. A string the override changed is served in the
229+
pair's translation of its key for that locale, else as written. A string it
230+
left alone takes the pair's translation, then the base's, locale by locale.
231+
Only `translation` items saved in the pair count, not an authored translation
232+
in no package. `GET /meta/:type/:name`, `GET /meta/:type` and the book tree
233+
`GET /meta/book/:name/tree` serve the same label.
234+
223235
Objects follow the same rule for their `label`, `pluralLabel` and
224236
`description`: a value that differs from the owning package's declaration (a
225237
label an [object extension](/docs/data-modeling/object-extensions) contributes,

‎packages/metadata-protocol/src/protocol-discovery-and-bases.ts‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
* `registeredTranslationBundle`, `readCodeLayerForCarryForward` moved out of
1111
* `protocol.ts` whole by `scripts/codemod-split-protocol.mjs`.
1212
*
13+
* [#30062] Written here from the start, beside their siblings: the page,
14+
* dataset, app and book packaged bases (`getPackagedPageBase`,
15+
* `getPackagedDatasetBase`, `getPackagedAppBase`, `getPackagedBookBase`), the
16+
* served-override reading of a served document (`servedOverrideBaseOf`) and the
17+
* pair's own translations (`getPairTranslationBundle`).
18+
*
1319
* A MOVE, not a redesign. `ProtocolDiscoveryAndBasesFacet` is a stateless facet over the host
1420
* (ruling 6624345766): each static method below is the body of the class
1521
* member of the same name as `packages/metadata-protocol/scripts/host-split.mjs`
@@ -29,10 +35,12 @@
2935
import type { SysMetadataRepository } from './sys-metadata-repository.js';
3036
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared';
3137
import type { TranslationBundle } from '@objectstack/spec/system';
38+
import { readAuthoredTranslationLayer } from '@objectstack/core';
3239
import { restoreTranslatedEchoes } from './protocol-translated-echo.js';
3340
import type { RestoredTranslatedSlot } from './protocol-translated-echo.js';
3441
import {
3542
DEFAULT_METADATA_TYPE_REGISTRY,
43+
customizationPackageId,
3644
getMetadataCreateSeed,
3745
getMetadataTypeActions,
3846
getMetadataTypeSchema,
@@ -47,8 +55,16 @@ import {
4755
} from './protocol-meta-type-schema.js';
4856
import type { SaveMetaItemRequest } from './protocol-door-requests.js';
4957
import type { ProtocolHost } from './protocol-host.js';
58+
import { servedOverrideBaseOf } from './served-override.js';
5059
import type { ObjectStackProtocolImplementation } from './protocol.js';
5160

61+
/**
62+
* [#30062] The types whose packaged base {@link ProtocolDiscoveryAndBasesFacet.packagedArtifactBase}
63+
* answers: the artifact item IS the packaged declaration for each (none has an
64+
* extension fold, unlike the object).
65+
*/
66+
export type PackagedArtifactBaseType = 'dashboard' | 'view' | 'report' | 'page' | 'dataset' | 'app' | 'book';
67+
5268
export class ProtocolDiscoveryAndBasesFacet {
5369
/** The body of `ObjectStackProtocolImplementation#getProjectId`; the method's docblock there is its contract. */
5470
static getProjectId(host: Pick<ProtocolHost, 'environmentId'>): string | undefined {
@@ -247,14 +263,70 @@ export class ProtocolDiscoveryAndBasesFacet {
247263
return host.packagedArtifactBase('report', name);
248264
}
249265

266+
/** The body of `ObjectStackProtocolImplementation#getPackagedPageBase`; the method's docblock there is its contract. */
267+
static getPackagedPageBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
268+
return host.packagedArtifactBase('page', name);
269+
}
270+
271+
/** The body of `ObjectStackProtocolImplementation#getPackagedDatasetBase`; the method's docblock there is its contract. */
272+
static getPackagedDatasetBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
273+
return host.packagedArtifactBase('dataset', name);
274+
}
275+
276+
/** The body of `ObjectStackProtocolImplementation#getPackagedAppBase`; the method's docblock there is its contract. */
277+
static getPackagedAppBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
278+
return host.packagedArtifactBase('app', name);
279+
}
280+
281+
/** The body of `ObjectStackProtocolImplementation#getPackagedBookBase`; the method's docblock there is its contract. */
282+
static getPackagedBookBase(host: Pick<ProtocolHost, 'packagedArtifactBase'>, name: string): unknown {
283+
return host.packagedArtifactBase('book', name);
284+
}
285+
286+
/** The body of `ObjectStackProtocolImplementation#servedOverrideBaseOf`; the method's docblock there is its contract. */
287+
static servedOverrideBaseOf(type: string, document: unknown): string | undefined {
288+
return servedOverrideBaseOf(type, document);
289+
}
290+
291+
/**
292+
* The body of `ObjectStackProtocolImplementation#getPairTranslationBundle`;
293+
* the method's docblock there is its contract.
294+
*
295+
* The rows are the environment's ({@link ObjectStackProtocolImplementation.findEnvironmentRows},
296+
* ADR-0131 D6/D7), narrowed to the pair's binding, and each is read into
297+
* its locale's data by `readAuthoredTranslationLayer` (`@objectstack/core`),
298+
* the function the i18n service's authored layer is built with: the same
299+
* conversions, the same locale resolution, the same bookkeeping stripped.
300+
* So the bundle is exactly the pair's share of the catalog the deployment
301+
* serves, with its package kept.
302+
*/
303+
static async getPairTranslationBundle(host: Pick<ProtocolHost, 'findEnvironmentRows'>, basePackageId: string): Promise<TranslationBundle | undefined> {
304+
if (typeof basePackageId !== 'string' || basePackageId === '') return undefined;
305+
const pair = customizationPackageId(basePackageId);
306+
if (!pair.ok) return undefined;
307+
const layer = await readAuthoredTranslationLayer({
308+
find: (table: string, opts: { where?: Record<string, unknown>; [key: string]: unknown } = {}) =>
309+
host.findEnvironmentRows(table, { ...opts, where: { ...opts.where, package_id: pair.value } }),
310+
});
311+
// `null` is a read that failed: the layer's own rule is "keep what you
312+
// had", and a translation input has nothing to keep, so the caller
313+
// gets "no pair translation known" — the answer it reads as before the
314+
// option existed (the override's own text), never a 5xx on a read of
315+
// the document itself.
316+
if (layer === null) return undefined;
317+
return Object.keys(layer).length > 0 ? (layer as TranslationBundle) : undefined;
318+
}
319+
250320
/**
251321
* The one body behind {@link getPackagedDashboardBase},
252-
* {@link getPackagedViewBase} and {@link getPackagedReportBase}:
322+
* {@link getPackagedViewBase}, {@link getPackagedReportBase} and
323+
* [#30062] {@link getPackagedPageBase}, {@link getPackagedDatasetBase},
324+
* {@link getPackagedAppBase} and {@link getPackagedBookBase}:
253325
* {@link lookupArtifactItem}, and nothing wider, so only an item whose
254326
* `_packageId` marks a genuine code package answers, and an overlay
255327
* hydrated under the plain registry key never does.
256328
*/
257-
static packagedArtifactBase(host: Pick<ProtocolHost, 'lookupArtifactItem'>, type: 'dashboard' | 'view' | 'report', name: string): unknown {
329+
static packagedArtifactBase(host: Pick<ProtocolHost, 'lookupArtifactItem'>, type: PackagedArtifactBaseType, name: string): unknown {
258330
if (typeof name !== 'string' || name === '') return undefined;
259331
try {
260332
return host.lookupArtifactItem(type, name);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30062, ADR-0146 D2/D3] `getPairTranslationBundle(base)`: the translations
5+
* a base's paired `.custom` package ships itself, locale-keyed, for the
6+
* translators' `pairBundle` (the first rank of ruling record 6631250894's
7+
* label order).
8+
*
9+
* The deployment catalog cannot answer this: the i18n service deep-merges
10+
* every code bundle and every authored `translation` row with the package ids
11+
* dropped. So the bundle is read from the pair's own active `translation` rows
12+
* by their binding, each read into its locale's data the way the i18n
13+
* service's authored layer reads it (`readAuthoredTranslationLayer`).
14+
*
15+
* The readings on a real boot (the row saved through the door, the served
16+
* override, the translator's answer) are pinned in `@objectstack/objectql`'s
17+
* `protocol-custom-override-bare-slot-envelope.test.ts`; this file pins the
18+
* edges a real boot does not reach.
19+
*/
20+
import { describe, expect, it } from 'vitest';
21+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
22+
import { ObjectStackProtocolImplementation } from './protocol.js';
23+
24+
const BASE = 'com.acme.crm';
25+
const PAIR = 'com.acme.crm.custom';
26+
27+
const translationRow = (id: string, packageId: string | null, data: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
28+
id, type: 'translation', name: id, package_id: packageId, organization_id: null, state: 'active',
29+
metadata: JSON.stringify({ name: id, ...data }), ...extra,
30+
});
31+
32+
type Row = Record<string, unknown>;
33+
34+
/** Plain equality and `null`; any operator or combinator is refused, so a matcher that ignored one cannot prove anything. */
35+
function matches(row: Row, where: Record<string, unknown> = {}): boolean {
36+
for (const [key, want] of Object.entries(where)) {
37+
if (want === undefined) continue;
38+
if (key.startsWith('$')) throw new Error(`stub engine: unsupported combinator ${key}`);
39+
if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`);
40+
if ((row[key] ?? null) !== want) return false;
41+
}
42+
return true;
43+
}
44+
45+
function protocolOver(rows: Row[], opts: { failRead?: boolean } = {}) {
46+
const asked: Row[] = [];
47+
const read = async (table: string, q?: { where?: Record<string, unknown>; limit?: number }) => {
48+
const found = table === 'sys_metadata' ? rows.filter((r) => matches(r, q?.where)) : [];
49+
// The caller's bound, after the filter, by presence — as the engine applies it.
50+
return typeof q?.limit === 'number' ? found.slice(0, q.limit) : found;
51+
};
52+
const engine = {
53+
registry: {},
54+
async find(table: string, q?: { where?: Record<string, unknown>; limit?: number }) {
55+
asked.push({ table, where: q?.where });
56+
if (opts.failRead) throw new Error('store unreachable');
57+
return read(table, q);
58+
},
59+
async findOne(table: string, q: { where: Record<string, unknown> }) {
60+
assertEngineFindOnePredicate(table, q);
61+
return (await read(table, q))[0] ?? null;
62+
},
63+
};
64+
return { protocol: new ObjectStackProtocolImplementation(engine as never, undefined, 'env_test'), asked };
65+
}
66+
67+
describe('[#30062] getPairTranslationBundle — the pair\'s own translations, by their binding', () => {
68+
it('answers the pair\'s active translation rows, locale-keyed, and no other package\'s', async () => {
69+
const { protocol, asked } = protocolOver([
70+
translationRow('pair_zh', PAIR, { locale: 'zh-CN', pages: { crm_welcome: { label: '新欢迎页' } } }),
71+
translationRow('pair_ja', PAIR, { locale: 'ja-JP', apps: { crm: { label: '顧客' } } }),
72+
translationRow('base_zh', BASE, { locale: 'zh-CN', pages: { crm_welcome: { label: '欢迎页' } } }),
73+
translationRow('loose_zh', null, { locale: 'zh-CN', pages: { crm_welcome: { label: '散行' } } }),
74+
translationRow('pair_draft', PAIR, { locale: 'zh-CN', pages: { crm_welcome: { label: '草稿' } } }, { state: 'draft' }),
75+
]);
76+
expect(await protocol.getPairTranslationBundle(BASE)).toEqual({
77+
'zh-CN': { pages: { crm_welcome: { label: '新欢迎页' } } },
78+
'ja-JP': { apps: { crm: { label: '顧客' } } },
79+
});
80+
// Every read names the pair's binding.
81+
expect(asked.length).toBeGreaterThan(0);
82+
for (const q of asked) expect((q.where as Record<string, unknown>).package_id).toBe(PAIR);
83+
});
84+
85+
it('strips the bookkeeping the publish stamps on a row, as the i18n service\'s layer does', async () => {
86+
const { protocol } = protocolOver([
87+
translationRow('pair_zh', PAIR, { locale: 'zh-CN', _packageId: PAIR, _provenance: 'org', pages: { p: { label: '页' } } }),
88+
]);
89+
expect(await protocol.getPairTranslationBundle(BASE)).toEqual({ 'zh-CN': { pages: { p: { label: '页' } } } });
90+
});
91+
92+
it('answers undefined for a pair that stores no translation', async () => {
93+
const { protocol } = protocolOver([translationRow('base_zh', BASE, { locale: 'zh-CN', pages: { p: { label: '页' } } })]);
94+
expect(await protocol.getPairTranslationBundle(BASE)).toBeUndefined();
95+
});
96+
97+
it('answers undefined, reading nothing, for an id that has no pair', async () => {
98+
for (const id of [PAIR, '', 'Not An Id', undefined as unknown as string]) {
99+
const { protocol, asked } = protocolOver([translationRow('pair_zh', PAIR, { locale: 'zh-CN', pages: {} })]);
100+
expect(await protocol.getPairTranslationBundle(id), String(id)).toBeUndefined();
101+
expect(asked, String(id)).toEqual([]);
102+
}
103+
});
104+
105+
it('answers undefined, never a throw, when the row read fails', async () => {
106+
const { protocol, asked } = protocolOver([], { failRead: true });
107+
await expect(protocol.getPairTranslationBundle(BASE)).resolves.toBeUndefined();
108+
expect(asked.length).toBeGreaterThan(0);
109+
});
110+
});

0 commit comments

Comments
 (0)