From 6fcd4edcdd6911d02db133369cf3146250219188 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:18:02 +0000 Subject: [PATCH 1/2] fix(rest): the by-name read of a view container translates it as a container The /meta item read handed a defineView container to the single-view translator, which keys the catalog on the view's name. An artifact-shipped container has none, so the artifact boot answered 500; the config boot's registrar stamps one, so that boot served a fabricated label and left every view inside untranslated. The read now classifies the served view document once: a container's views are translated under the names the composer gives them, with their own packaged bases, and land back in their slots. Claude-Session: https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH Co-authored-by: Claude --- packages/rest/package.json | 2 +- packages/rest/src/meta-item-read-gate.ts | 176 +++++++++++++++++++++++ pnpm-lock.yaml | 6 +- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/packages/rest/package.json b/packages/rest/package.json index 9e4f9c9f914..e923167a324 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/metadata": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/observability": "workspace:*", "@objectstack/platform-objects": "workspace:*", @@ -40,7 +41,6 @@ }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", - "@objectstack/metadata": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index ef794f2e6c0..fa91bb79515 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -70,6 +70,10 @@ import { type ObjectSchemaMaskPosture, } from '@objectstack/metadata-core'; import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; +// [#22614] The registry's one spelling of a container's binding, through its +// import-free leaf entry (that module's header measures why the leaf exists). +import { deriveViewContainerObject } from '@objectstack/metadata/view-container'; +import type { ExpandedViewItem } from '@objectstack/spec/ui'; import { isEndpointMatchAuthority, selectServedEndpoints } from './served-endpoints.js'; import { logError, logWarn } from './log.js'; @@ -2233,6 +2237,13 @@ export async function translateMetaList( * Moved here, unchanged, from `RestServer.translateMetaItem` (which now * delegates), so the runtime dispatcher's item read translates with the same * function instead of serving the authored labels. + * + * [#22614] A `view` document is classified ONCE, here, before any translator + * reads it: a view CONTAINER goes to {@link translateMetaViewContainer}, a + * single view to the spec's view translator. Both shapes are served under the + * type `view` — the registry files a `defineView` container under the object + * it binds and serves it on that key's by-name read — and the spec translator + * the table dispatches `view` to reads a single view's `name`. */ export async function translateMetaDocument( sources: MetaListTranslationSources, @@ -2245,6 +2256,17 @@ export async function translateMetaDocument( const bundle = translationBundleOf(i18n); const locale = sources.requestLocale(i18n); if (!locale) return document; + if (metaType === 'view') { + const { isAggregatedViewContainer } = await import('@objectstack/spec/ui'); + if (isAggregatedViewContainer(document)) { + return translateMetaViewContainer( + sources, + document as Record, + bundle, + metaTranslateOptions(i18n, locale), + ); + } + } const { translateMetadataDocument } = await import('@objectstack/spec/system'); const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType) ? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name) @@ -2255,6 +2277,160 @@ export async function translateMetaDocument( }); } +/** + * [#22614] Translate a served view CONTAINER as a container — the by-name read + * of a `defineView` document, which the registry files under the object it + * binds ({@link deriveViewContainerObject}). + * + * The spec's `translateView` is the translator for ONE view, and it keys the + * catalog on that view's `name`. A container has no `name` of its own when a + * compiled artifact ships it (only the config boot's registrar stamps one), so + * handing the container to it threw on the artifact boot, and on the config + * boot it served the container as one view keyed by the object: a fabricated + * `label` equal to the object name, and every view inside left untranslated. + * ⛔ Guarding the `name` read instead would leave the container untranslated + * in silence. + * + * So a container is translated in two parts, each through `translateView` + * under its own key rule: + * + * - **each view it expands to**, under the name the composer gives it + * (`expandViewContainer`, the one producer of a view's runtime identity, the + * same expansion the registrars run), with that view's packaged base looked + * up by that name — exactly what the by-name read of the expanded view + * (`.`) is translated with, so the two reads cannot disagree. + * The result lands back in the slot the view came from; + * - **the container's own `label` and `description`**, keyed by the object it + * binds, with the packaged container as the base. + * + * A string the catalog does not carry is never invented: the translator falls + * back to a view's `name` for a missing label, and that fallback is not a + * translation ({@link projectViewTranslation}). With no bundle, nothing + * changes and the SAME container is returned. + * + * Known limit: a container saved at runtime on ANOTHER package's object is + * expanded by the metadata protocol under its own name (`..`), + * which this function does not reproduce; its views are translated under the + * composer's names. + */ +async function translateMetaViewContainer( + sources: MetaListTranslationSources, + container: Record, + bundle: unknown, + options: ReturnType, +): Promise> { + const object = deriveViewContainerObject(container); + // The registrars file nothing they cannot bind, so no read serves such a container. + if (object === undefined) return container; + const [{ translateView }, { expandViewContainer }] = await Promise.all([ + import('@objectstack/spec/system'), + import('@objectstack/spec/ui'), + ]); + const protocol = await sources.resolveProtocol(); + const translate = (view: Record, baseName: unknown): Record => + translateView(view as any, bundle as any, { + ...options, + packagedBase: packagedObjectBaseOf(protocol, 'view', baseName), + }) as unknown as Record; + + // The container's own strings. The key the registry files it under is its + // `name` when it carries one (a source registrar refuses a `name` that is + // not the object, a save stamps the save name), else the object. + const ownName = typeof container.name === 'string' && container.name !== '' ? container.name : object; + const ownInput = { ...container, name: object, object }; + let next = projectViewTranslation(container, ownInput, translate(ownInput, ownName)); + + // Each view, by the item the composer expands its slot into. + const items = expandViewContainer(object, container) as ExpandedViewItem[]; + const lists = items.filter((item) => item.viewKind === 'list'); + const forms = items.filter((item) => item.viewKind === 'form'); + const slotTranslation = (slot: unknown, item: ExpandedViewItem | undefined): unknown => { + if (item === undefined || !slot || typeof slot !== 'object' || Array.isArray(slot)) return slot; + const input = item as unknown as Record; + return projectViewTranslation(slot as Record, input, translate(input, item.name)); + }; + const put = (key: string, value: unknown): void => { + if (value === next[key]) return; + if (next === container) next = { ...container }; + next[key] = value; + }; + const slotRecord = (key: string, itemFor: (slotKey: string, slot: unknown) => ExpandedViewItem | undefined): void => { + const record = next[key]; + if (!record || typeof record !== 'object' || Array.isArray(record)) return; + let out = record as Record; + for (const [slotKey, slot] of Object.entries(record)) { + const translated = slotTranslation(slot, itemFor(slotKey, slot)); + if (translated === slot) continue; + if (out === record) out = { ...(record as Record) }; + out[slotKey] = translated; + } + put(key, out); + }; + // The composer's order, from its docblock: each family's NAMED views first, + // in entry order, then the default. A `listViews` entry is the first claim + // on its `.`, so it is never renamed and is found by name. The + // default `list` and the default `form` are the items the composer flags + // `isDefault` (a `list` restating a named list collapses into that item; a + // named list is flagged only when there is no `list`). A `formViews` entry + // can be renamed by a list that took its name, so it is found by position + // among the named forms — entries that are not objects expand to nothing. + slotRecord('listViews', (slotKey) => lists.find((item) => item.name === `${object}.${slotKey}`)); + if (next.list && typeof next.list === 'object') { + put('list', slotTranslation(next.list, lists.find((item) => item.isDefault))); + } + const namedForms = forms.filter((item) => !item.isDefault); + let formPosition = 0; + slotRecord('formViews', (_slotKey, slot) => + slot && typeof slot === 'object' ? namedForms[formPosition++] : undefined); + if (next.form && typeof next.form === 'object') { + put('form', slotTranslation(next.form, forms.find((item) => item.isDefault))); + } + return next; +} + +/** + * [#22614] Land what `translateView` changed on `input` onto `target` — the + * container itself, or the slot `input` was expanded from — copy on write, + * `target` itself when nothing changed. + * + * Every top-level string the translator overlays (`label`, `description`) + * lands on `target`, and so does every key it replaced in the view's `config` + * (`bulkActionDefs`): an expanded view nests its whole list or form body under + * `config`, and the slot IS that body. The expansion's own identity keys are + * never copied. A `label` that equals `input.name` where `input` had none is + * the translator's fallback, not a translation, and is dropped. + */ +function projectViewTranslation( + target: Record, + input: Record, + translated: Record, +): Record { + let out = target; + const set = (key: string, value: unknown): void => { + if (value === out[key]) return; + if (out === target) out = { ...target }; + out[key] = value; + }; + for (const [key, value] of Object.entries(translated)) { + if (key === 'config' || VIEW_EXPANSION_IDENTITY_KEYS.has(key) || value === input[key]) continue; + if (key === 'label' && input.label == null && value === input.name) continue; + set(key, value); + } + const config = translated.config; + const inputConfig = input.config; + if (config !== inputConfig && config && typeof config === 'object' && inputConfig && typeof inputConfig === 'object') { + for (const [key, value] of Object.entries(config)) { + if (value !== (inputConfig as Record)[key]) set(key, value); + } + } + return out; +} + +/** The keys `expandViewContainer` stamps on an item — its identity, never the view's own content. */ +const VIEW_EXPANSION_IDENTITY_KEYS: ReadonlySet = new Set([ + 'name', 'object', 'viewKind', 'order', 'scope', 'isDefault', '_diagnostics', +]); + /** * [#5563 · #10235 · #20408] The one place a single-item read rebuilds its * response body around the document it serves: `envelope` supplies the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2439f63a672..38519a394cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2242,6 +2242,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/metadata': + specifier: workspace:* + version: link:../metadata '@objectstack/metadata-core': specifier: workspace:* version: link:../metadata-core @@ -2270,9 +2273,6 @@ importers: '@objectstack/driver-sql': specifier: workspace:* version: link:../drivers/driver-sql - '@objectstack/metadata': - specifier: workspace:* - version: link:../metadata '@objectstack/metadata-protocol': specifier: workspace:* version: link:../metadata-protocol From f63e6a1d123890c45850a9a460b974cf7fac51a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:22:40 +0000 Subject: [PATCH 2/2] test(rest,cli): pin the view-container by-name read on both boots, and its changeset Claude-Session: https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH Co-authored-by: Claude --- .changeset/22614-meta-view-container-read.md | 15 + ...ve-view-container-read.integration.test.ts | 323 ++++++++++++++++++ .../rest/src/meta-view-container-i18n.test.ts | 204 +++++++++++ 3 files changed, 542 insertions(+) create mode 100644 .changeset/22614-meta-view-container-read.md create mode 100644 packages/cli/test/serve-view-container-read.integration.test.ts create mode 100644 packages/rest/src/meta-view-container-i18n.test.ts diff --git a/.changeset/22614-meta-view-container-read.md b/.changeset/22614-meta-view-container-read.md new file mode 100644 index 00000000000..522d728ffce --- /dev/null +++ b/.changeset/22614-meta-view-container-read.md @@ -0,0 +1,15 @@ +--- +'@objectstack/rest': patch +--- + +`GET /api/v1/meta/view/`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot + +Clause-②: no + +The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`. + +- **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`. +- **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: ` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served. +- **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`.`). That copy is keyed by the same `objects.._views.` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.._views.`, and are left unchanged when the catalog has no entry there. +- **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container. +- **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds. diff --git a/packages/cli/test/serve-view-container-read.integration.test.ts b/packages/cli/test/serve-view-container-read.integration.test.ts new file mode 100644 index 00000000000..df0153f2c18 --- /dev/null +++ b/packages/cli/test/serve-view-container-read.integration.test.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22614 — the by-name read of a `defineView` container answers the same on an + * ARTIFACT boot and on a CONFIG boot of one single-package project, with every + * view inside it translated. + * + * ## The defect, as measured through the doors + * + * `GET /api/v1/meta/view/acme_note`, authenticated, where `acme_note` is the + * key the registry files the project's `defineView({ object: 'acme_note', … })` + * container under: + * + * - `os build`, then `os serve` of the artifact: `500 INTERNAL_ERROR`, the log + * reading `Cannot read properties of undefined (reading 'startsWith')`. The + * compiled container carries no `name`, and the read handed it to the + * single-view translator, which keys the catalog on `name`; + * - `os serve objectstack.config.ts`: `200`, from a container the config + * boot's registrar had stamped `name: 'acme_note'` onto — served with a + * fabricated `label: 'acme_note'` and both views inside it in English for a + * `zh-CN` reader. + * + * The read now classifies the served view document once and translates a + * container as a container (`translateMetaDocument`, `@objectstack/rest`; + * pinned function-level by `meta-view-container-i18n.test.ts` there). + * + * ## What each boot is asked + * + * - THE CARD'S READ, in `zh-CN` and in `en`: the container, whose views carry + * the catalog's labels for the reader's locale, and no label the project + * never wrote; + * - CONTROL: the by-name reads of the two views it expands to + * (`acme_note.default`, `acme_note.mine`), translated as they always were. + * + * ⚠️ Not counts: each assertion reads a label the catalog carries for exactly + * one view and one locale, so a read that stopped translating, or translated + * under the wrong key, names the view it got wrong. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { existsSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLI, + E2E_SECRET_KEY, + TSX, + childEnv, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, +} from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +/** The banner's tail — every row above it has printed. */ +const READY = /Press Ctrl\+C to stop/; + +const OBJ = 'acme_note'; + +/** One package, no `packages[]`; one container with a default list and one named list, and a catalog for both. */ +const CONFIG = ` +import { defineStack } from '@objectstack/spec'; +const grid = (label) => ({ type: 'grid', label, data: { provider: 'object', object: '${OBJ}' }, columns: ['title'] }); +const noteObj = { name: '${OBJ}', label: 'Note', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }; +const noteViews = { object: '${OBJ}', list: grid('All Notes'), listViews: { mine: grid('My Notes') } }; +const app = { id: 'com.example.acme', name: 'acme', namespace: 'acme', version: '1.0.0', type: 'app' }; +const translations = [{ + en: { objects: { ${OBJ}: { label: 'Note', _views: { default: { label: 'All Notes' }, mine: { label: 'My Notes' } } } } }, + 'zh-CN': { objects: { ${OBJ}: { label: '笔记', _views: { default: { label: '全部笔记' }, mine: { label: '我的笔记' } } } } }, +}]; +export default defineStack({ manifest: app, objects: [noteObj], views: [noteViews], translations }, { strict: false }); +`; + +const dirs: string[] = []; +const children: ChildProcessWithoutNullStreams[] = []; + +function project(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); + linkSpec(dir); + return dir; +} + +interface Run { code: number; stdout: string; stderr: string } + +/** `os build` in `dir`, through the source entry. */ +function build(dir: string): Promise { + return new Promise((done) => { + execFile( + TSX, + [CLI, 'build'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + { cwd: dir, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => done({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }), + ); + }); +} + +/** `os build`, then take the config away so `os serve` boots the compiled artifact. */ +async function buildArtifact(dir: string): Promise { + const built = await build(dir); + if (built.code !== 0 || !existsSync(join(dir, 'dist', 'objectstack.json'))) { + throw new Error(`os build failed in ${dir} (exit ${built.code})\n${built.stdout}\n${built.stderr}`); + } + renameSync(join(dir, 'objectstack.config.ts'), join(dir, 'objectstack.config.ts.off')); +} + +interface LiveServe { + child: ChildProcessWithoutNullStreams; + output: () => string; +} + +/** Boot `os serve` in `dir` with `args`; the source entry seeds the dev admin either way. */ +function bootServe(dir: string, args: string[], port: string): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, 'serve', ...args, '-p', port], { + cwd: dir, + stdio: ['pipe', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: 'warn', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + }), + }) as ChildProcessWithoutNullStreams; + children.push(child); + + const what = `os serve ${args.join(' ')} in ${dir}`; + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + 180_000, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound (#12525). + if (READY.test(out)) settle(portDriftError(out, what, port)); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => + settle( + portContentionError(out, what, port) + ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), + ), + ); + }); +} + +async function stop(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already gone */ + } + done(); + }, 10_000); + child.once('exit', () => { + clearTimeout(give); + done(); + }); + try { + child.kill('SIGTERM'); + } catch { + clearTimeout(give); + done(); + } + }); +} + +/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */ +async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { + return probeThroughChild( + { + child: serve.child, + transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`, + label: 'serve-view-container-read', + what, + }, + async () => { + const r = await fetch(url, init); + const text = await r.text(); + let body: unknown = text; + try { body = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body }; + }, + ); +} + +/** One by-name read, as a boot answered it. */ +interface Answer { status: number; item: any; error?: unknown } + +/** What one boot answers for the container and for the two views it expands to, per locale. */ +type Reading = Record; + +const READS = [OBJ, `${OBJ}.default`, `${OBJ}.mine`]; +const LOCALES = ['zh-CN', 'en']; + +/** Boot, sign in as the seeded admin, read every by-name view in every locale, stop. */ +async function read(dir: string, args: string[]): Promise<{ reading: Reading; output: string }> { + const port = randomPort(); + const base = `http://localhost:${port}/api/v1`; + const serve = await bootServe(dir, args, port); + try { + const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`); + } + const reading: Reading = {}; + for (const name of READS) { + for (const locale of LOCALES) { + const answer = await request(serve, `the read of view ${name} in ${locale}`, `${base}/meta/view/${name}`, { + headers: { authorization: `Bearer ${token}`, 'accept-language': locale }, + }); + reading[`${name} ${locale}`] = { + status: answer.status, + item: answer.body?.item, + ...(answer.status === 200 ? {} : { error: answer.body }), + }; + } + } + return { reading, output: serve.output() }; + } finally { + await stop(serve.child); + } +} + +/** The view content of a served container — what a translation can change, without the registrar's stamps. */ +function containerContent(item: any): Record { + const { object, label, list, listViews, form, formViews } = item ?? {}; + return { object, label, list, listViews, form, formViews }; +} + +afterAll(async () => { + for (const child of children) await stop(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}, 60_000); + +describe('#22614 — the by-name read of a view container, on an artifact boot and on a config boot', () => { + let config: { reading: Reading; output: string }; + let artifact: { reading: Reading; output: string }; + + beforeAll(async () => { + config = await read(project('view-container-read-config-'), ['objectstack.config.ts']); + const built = project('view-container-read-artifact-'); + await buildArtifact(built); + artifact = await read(built, []); + }, 480_000); + + it.each([ + ['the artifact boot', () => artifact], + ['the config boot', () => config], + ])('%s answers 200 for the container, its views translated for a zh-CN reader', (_door, boot) => { + const { reading, output } = boot(); + const answer = reading[`${OBJ} zh-CN`]; + // Before the fix: the artifact boot answered 500 here. + expect(answer.status, `${JSON.stringify(answer.error)}\n--- output ---\n${output.slice(-3000)}`).toBe(200); + // Before the fix: the config boot served both in English. + expect(answer.item.list.label).toBe('全部笔记'); + expect(answer.item.listViews.mine.label).toBe('我的笔记'); + // Before the fix: the config boot served a `label` equal to the object name. + expect(answer.item).not.toHaveProperty('label'); + expect(output).not.toMatch(/reading 'startsWith'/); + }); + + it.each([ + ['the artifact boot', () => artifact], + ['the config boot', () => config], + ])('%s serves the authored labels to an en reader', (_door, boot) => { + const answer = boot().reading[`${OBJ} en`]; + expect(answer.status, JSON.stringify(answer.error)).toBe(200); + expect(answer.item.list.label).toBe('All Notes'); + expect(answer.item.listViews.mine.label).toBe('My Notes'); + }); + + it('the two boots serve the same container', () => { + for (const locale of LOCALES) { + expect(containerContent(config.reading[`${OBJ} ${locale}`].item), locale) + .toEqual(containerContent(artifact.reading[`${OBJ} ${locale}`].item)); + } + }); + + it('CONTROL — the by-name reads of the views it expands to are translated as before, alike on both boots', () => { + const expected: Record = { + [`${OBJ}.default zh-CN`]: '全部笔记', + [`${OBJ}.default en`]: 'All Notes', + [`${OBJ}.mine zh-CN`]: '我的笔记', + [`${OBJ}.mine en`]: 'My Notes', + }; + for (const [key, label] of Object.entries(expected)) { + for (const boot of [artifact, config]) { + const answer = boot.reading[key]; + expect(answer.status, `${key}: ${JSON.stringify(answer.error)}`).toBe(200); + expect(answer.item.label, key).toBe(label); + } + } + }); +}); diff --git a/packages/rest/src/meta-view-container-i18n.test.ts b/packages/rest/src/meta-view-container-i18n.test.ts new file mode 100644 index 00000000000..575f140240a --- /dev/null +++ b/packages/rest/src/meta-view-container-i18n.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22614 — the by-name `/meta` read of a view CONTAINER translates it as a + * container. + * + * The registry files a `defineView` container under the object it binds and + * serves it on that key's by-name read. The read handed it to the spec's + * single-view translator, which keys the catalog on the view's `name`: + * + * - an ARTIFACT-shipped container has no `name` — `GET /meta/view/` + * answered `500` (`reading 'startsWith'`); + * - the CONFIG boot's registrar stamps `name: ` — the same read + * answered `200` with a fabricated `label` equal to the object name and + * every view inside the container left in the authored language. + * + * `translateMetaDocument` now classifies the document once. Pinned here, over + * the function both transports call: the two registrar shapes translate alike; + * each view in the container is translated exactly as the by-name read of the + * view it expands to (the CONTROL, unchanged); the composer's names decide the + * catalog key in every corner it names (a collapsed default list, a renamed + * default, a renamed named form, the default form); each view's packaged base + * is looked up by its qualified name, so an explicit override still beats the + * catalog; the container's own strings are keyed by its object; nothing the + * catalog does not carry is invented. + * + * The boot-level twin — a real config boot and a real artifact boot of one + * project answering the same — is + * `packages/cli/test/serve-view-container-read.integration.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { translateMetaDocument, type MetaListTranslationSources } from './meta-item-read-gate.js'; + +const OBJ = 'acme_note'; + +const grid = (label?: string, extra: Record = {}) => ({ + type: 'grid', + ...(label !== undefined ? { label } : {}), + data: { provider: 'object', object: OBJ }, + columns: ['title'], + ...extra, +}); + +/** A container as `os build` ships it: no `name`. */ +const shipped = (body: Record): Record => ({ object: OBJ, ...body }); + +const BUNDLE: Record = { + en: { objects: { [OBJ]: { _views: {} } } }, + 'zh-CN': { + objects: { + [OBJ]: { + _views: { + default: { label: '全部笔记' }, + mine: { label: '我的笔记', description: '我创建的笔记' }, + all: { label: '所有' }, + default_2: { label: '默认列表(改名)' }, + form: { label: '笔记表单' }, + edit: { label: '编辑' }, + edit_2: { label: '编辑表单(改名)' }, + [OBJ]: { label: '笔记视图' }, + bulk: { bulkActions: { close_all: { label: '全部关闭', confirmText: '确定关闭?' } } }, + }, + }, + }, + }, +}; + +const i18nService = { + getLocales: () => Object.keys(BUNDLE), + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +/** A protocol whose packaged views are `packaged`, recording every name the read asks it for. */ +function protocolOf(packaged: Record = {}) { + const asked: string[] = []; + return { + asked, + getPackagedViewBase(name: string) { + asked.push(name); + return packaged[name]; + }, + }; +} + +function sources(locale: string | undefined, protocol: unknown = protocolOf()): MetaListTranslationSources { + return { + resolveI18nService: async () => i18nService, + resolveProtocol: async () => protocol, + requestLocale: () => locale, + }; +} + +const read = (doc: unknown, locale: string | undefined = 'zh-CN', protocol?: unknown) => + translateMetaDocument(sources(locale, protocol), 'view', doc) as Promise; + +describe('#22614 — a view container is translated as a container', () => { + const container = shipped({ list: grid('All Notes'), listViews: { mine: grid('My Notes') } }); + + it('the artifact shape (no `name`) answers, with every view translated', async () => { + const out = await read(container); + expect(out.list.label).toBe('全部笔记'); + expect(out.listViews.mine.label).toBe('我的笔记'); + expect(out.listViews.mine.description).toBe('我创建的笔记'); + // The container's own label: the catalog carries one for its object. + expect(out.label).toBe('笔记视图'); + // Nothing else moved, and the served document was not mutated. + expect(out.list.columns).toEqual(['title']); + expect(container.list.label).toBe('All Notes'); + expect('name' in out).toBe(false); + }); + + it('the config-boot shape (`name` stamped as the object) translates exactly alike', async () => { + const artifact = await read(container); + const config = await read({ ...container, name: OBJ }); + expect(config).toEqual({ ...artifact, name: OBJ }); + }); + + it('CONTROL — every view in the container matches the by-name read of the view it expands to, which is unchanged', async () => { + const out = await read(container); + const items = expandViewContainer(OBJ, container); + const byName = Object.fromEntries( + await Promise.all(items.map(async (item) => [item.name, await read(item)] as const)), + ); + expect(byName[`${OBJ}.default`].label).toBe('全部笔记'); + expect(byName[`${OBJ}.mine`].label).toBe('我的笔记'); + expect(out.list.label).toBe(byName[`${OBJ}.default`].label); + expect(out.listViews.mine.label).toBe(byName[`${OBJ}.mine`].label); + expect(out.listViews.mine.description).toBe(byName[`${OBJ}.mine`].description); + }); + + it('invents nothing: a view and a container the catalog does not carry keep their authored shape', async () => { + const bare = { object: 'acme_other', list: { ...grid(), data: { provider: 'object', object: 'acme_other' } } }; + const out = await read(bare); + // `translateView` falls back to a view's NAME for a missing label — not a translation. + expect(out).toEqual(bare); + expect('label' in out).toBe(false); + expect('label' in out.list).toBe(false); + }); + + it('returns the SAME document when no bundle string applies, and when no locale is resolved', async () => { + const plain = shipped({ list: grid('All Notes') }); + const en = await read(plain, 'en'); + expect(en).toBe(plain); + expect(await translateMetaDocument(sources(undefined), 'view', plain)).toBe(plain); + }); + + it("the composer's names decide every key: a default list restating a named list is that list's", async () => { + // The `examples/app-crm` shape: `list` restates `listViews.all` key for key. + const out = await read(shipped({ list: grid('All'), listViews: { all: grid('All') } })); + expect(out.listViews.all.label).toBe('所有'); + expect(out.list.label).toBe('所有'); + }); + + it("…a default list whose key a named list took is the composer's renamed key", async () => { + const out = await read(shipped({ list: grid('Default'), listViews: { default: grid('Named default', { columns: ['body'] }) } })); + expect(out.listViews.default.label).toBe('全部笔记'); + expect(out.list.label).toBe('默认列表(改名)'); + }); + + it('…a named form a list took the key of is renamed, and the default form keeps `form`', async () => { + const form = (label: string) => ({ type: 'simple', label, sections: [{ fields: [{ field: 'title' }] }] }); + const doc = shipped({ + listViews: { edit: grid('Edit list') }, + formViews: { skipped: null, edit: form('Edit form') }, + form: form('Note form'), + }); + const out = await read(doc); + expect(expandViewContainer(OBJ, doc).map((item) => item.name)).toEqual([ + `${OBJ}.edit`, `${OBJ}.edit_2`, `${OBJ}.form`, + ]); + expect(out.listViews.edit.label).toBe('编辑'); + expect(out.formViews.edit.label).toBe('编辑表单(改名)'); + expect(out.formViews.skipped).toBeNull(); + expect(out.form.label).toBe('笔记表单'); + }); + + it("translates a list view's bulk-action defs in its slot", async () => { + const out = await read(shipped({ + listViews: { bulk: grid('Bulk', { bulkActionDefs: [{ name: 'close_all', label: 'Close all', confirmText: 'Sure?' }] }) }, + })); + expect(out.listViews.bulk.bulkActionDefs).toEqual([ + { name: 'close_all', label: '全部关闭', confirmText: '确定关闭?' }, + ]); + }); + + it("looks each view's packaged base up by its qualified name, so an explicit override beats the catalog", async () => { + const packaged = shipped({ list: grid('All Notes'), listViews: { mine: grid('My Notes') } }); + const [packagedDefault, packagedMine] = expandViewContainer(OBJ, packaged).sort((a, b) => a.name.localeCompare(b.name)); + const protocol = protocolOf({ + [OBJ]: packaged, + [`${OBJ}.default`]: packagedDefault, + [`${OBJ}.mine`]: packagedMine, + }); + // An org overlay edited the named view's label; the default list is as shipped. + const overlay = shipped({ list: grid('All Notes'), listViews: { mine: grid('My Notes (edited)') } }); + const out = await read(overlay, 'zh-CN', protocol); + expect(out.listViews.mine.label).toBe('My Notes (edited)'); + expect(out.list.label).toBe('全部笔记'); + expect([...protocol.asked].sort()).toEqual([OBJ, `${OBJ}.default`, `${OBJ}.mine`]); + }); +});