diff --git a/.changeset/23967-view-label-not-invented.md b/.changeset/23967-view-label-not-invented.md new file mode 100644 index 00000000000..9cee155f45d --- /dev/null +++ b/.changeset/23967-view-label-not-invented.md @@ -0,0 +1,22 @@ +--- +'@objectstack/rest': minor +--- + +fix(rest)!: the `/meta` item and list reads serve a view stored with no `label` without the `label` the translator invents from its name + +Clause-②: no (narrowing) + + + +**BREAKING** (a `/meta` read stops serving a value nobody authored), shipped as `minor` under the launch-window convention for breaking changes. + +A view's `label` is optional in the spec, with no default. When the translation catalog carried no string for a view, the read-side translator answered the view's own name, so a view stored without a `label` was served one: `GET /api/v1/meta/view/NAME` and `GET /api/v1/meta/view` returned `label: "OBJECT.KEY"`, on the standalone server and on the runtime dispatcher alike. The console drew that machine name as the view tab. The view-container read already dropped this value; the item read and the list read now drop it too. + +What a client reads now: + +- A view stored with no `label` is served with no `label` key, in every locale, with or without a translation catalog. +- A view with an authored `label` is served that label, unchanged, including a label that happens to equal the view's name. +- A catalog translation (`objects.OBJECT._views.KEY.label`) is still served, for a view with a `label` and for one without. +- A read that resolves no locale is served the stored document as before, and the layered read (`?layers=true`) never carried the invented value. + +Who reads differently: a client that displays a served view's `label` now finds the key absent for a view stored without one, where it used to find the qualified name; it shows its own wording for such a view (the object's plural label, for example). The console's view tab falls back to the view's name on its own, so it keeps showing a machine name for such a view until its own fallback changes. diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 93e97830955..8fd8cdef649 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -2794,6 +2794,10 @@ export interface MetaListTranslationSources { * applies built-in fallbacks (the injected system-field labels on custom * objects). No locale is: nothing is translated then. * + * [#23967] A `view` element is served without the `label` the translator + * falls back to when the view stored none ({@link withoutInventedViewLabel}), + * the rule the item read and the container read apply. + * * `RestServer.translateMetaItems` delegates here, and the list chain * ({@link createMetaListAnswer}) runs it as its last step on both transports. */ @@ -2820,10 +2824,13 @@ export async function translateMetaList( const p = PACKAGED_BASE_ACCESSORS.has(metaType) ? await sources.resolveProtocol() : undefined; // `getMetaItems` elements are metadata documents (the list envelope is the // OUTER `{ type, items }`), so every element translates directly (#5563). - const translated = arr.map((item) => translateMetadataDocument(metaType, item, bundle, { - ...metaTranslateOptions(i18n, locale), - packagedBase: packagedObjectBaseOf(p, metaType, item?.name), - })); + const translated = arr.map((item) => { + const out = translateMetadataDocument(metaType, item, bundle, { + ...metaTranslateOptions(i18n, locale), + packagedBase: packagedObjectBaseOf(p, metaType, item?.name), + }); + return metaType === 'view' && item && typeof item === 'object' ? withoutInventedViewLabel(item, out) : out; + }); return Array.isArray(raw) ? translated : { ...raw, items: translated }; } @@ -2851,6 +2858,10 @@ export async function translateMetaList( * 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`. + * + * [#23967] A single view is served without the `label` that translator falls + * back to when the view stored none ({@link withoutInventedViewLabel}) — the + * rule the container read and the list read apply. */ export async function translateMetaDocument( sources: MetaListTranslationSources, @@ -2878,10 +2889,11 @@ export async function translateMetaDocument( const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType) ? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name) : undefined; - return translateMetadataDocument(metaType, document as any, bundle, { + const translated = translateMetadataDocument(metaType, document as any, bundle, { ...metaTranslateOptions(i18n, locale), packagedBase, }); + return metaType === 'view' ? withoutInventedViewLabel(document as Record, translated) : translated; } /** @@ -2912,8 +2924,9 @@ export async function translateMetaDocument( * * 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. + * translation ({@link withoutInventedViewLabel}, through + * {@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 (`..`), @@ -3004,8 +3017,8 @@ async function translateMetaViewContainer( * 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. + * never copied. The translator's `label` fallback is not a translation and + * never lands ({@link withoutInventedViewLabel}). */ function projectViewTranslation( target: Record, @@ -3018,9 +3031,8 @@ function projectViewTranslation( if (out === target) out = { ...target }; out[key] = value; }; - for (const [key, value] of Object.entries(translated)) { + for (const [key, value] of Object.entries(withoutInventedViewLabel(input, 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; @@ -3033,6 +3045,38 @@ function projectViewTranslation( return out; } +/** + * [#22614 · #23967] `translated` — what the spec's `translateView` answered for + * the view `input` — without the `label` that translator invents. It answers + * `input.label ?? input.name` when the catalog carries no string for the view, + * so a view stored with no `label` comes back carrying its own name, and the + * spec declares a view's `label` optional with no default: that value was + * authored by nobody and translated from nothing, and a reader could tell it + * from a real label only by comparing it to `name`. + * + * The rule: a `label` equal to `input.name` where `input` carried none (absent + * or nullish) is the fallback, and `input`'s own value takes its place — no + * `label` key when `input` had none. An authored `label`, even one equal to the + * view's name, and a catalog string that differs from the name are served + * unchanged; a catalog string equal to the name cannot be told from the + * fallback and goes with it. + * + * ONE rule for every `view` read this module translates: the item read + * ({@link translateMetaDocument}), the list read ({@link translateMetaList}) + * and each part of a container ({@link projectViewTranslation}). Returns + * `translated` itself when nothing is dropped. + */ +function withoutInventedViewLabel( + input: Record, + translated: Record, +): Record { + if (input.label != null || translated.label !== input.name) return translated; + const served: Record = { ...translated }; + if ('label' in input) served.label = input.label; + else delete served.label; + return served; +} + /** 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', diff --git a/packages/rest/src/meta-view-label-not-invented.test.ts b/packages/rest/src/meta-view-label-not-invented.test.ts new file mode 100644 index 00000000000..a98b2ba27ad --- /dev/null +++ b/packages/rest/src/meta-view-label-not-invented.test.ts @@ -0,0 +1,335 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23967 — the `/meta` single-item and list reads serve a view stored with no + * `label` WITHOUT the `label` the read-side translator invents from its name. + * + * `ViewItemSchema` declares a view's `label` optional, with no default. The + * spec's `translateView` answers `view.label ?? view.name` when the catalog + * carries no string for the view, so a view stored without one was served + * `label: '.'` on `GET /meta/view/NAME` and on `GET /meta/view`, + * and the console drew `recheck_gate.default` as the view tab. The container + * read (#22614) already dropped that same fallback; the item and list reads + * now take the same rule. + * + * Pinned over both hosts' read path: + * + * - §1 through the standalone server's ROUTES (`RestServer`, which delegates + * to `translateMetaDocument` / `translateMetaList`), over a REAL registry and + * the REAL metadata protocol, the stored view seeded as the row a runtime + * save writes; + * - §2 through the two functions the runtime dispatcher's `/meta` domain + * imports from this package (`translateMetaEnvelope`, `translateMetaList`). + * + * Covered on each: a label-less stored view is served with no `label`; an + * authored label is served as authored (one equal to the view's own name + * included — the rule drops only what nobody wrote); a catalog translation for + * a label-less view is served; a view that has a label is served its + * translation. §3 holds the container read's answer to what it was. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { + translateMetaDocument, + translateMetaEnvelope, + translateMetaList, + type MetaListTranslationSources, +} from './meta-item-read-gate.js'; + +// --------------------------------------------------------------------------- +// Fixtures — the card's recipe: an object with a label, views on it +// --------------------------------------------------------------------------- + +const OBJ = 'recheck_gate'; +const PKG = 'com.qa.recheck'; + +const config = () => ({ type: 'grid', columns: ['name', 'kind'] }); + +/** The card's step 3 body: a list view PUT with NO `label`. */ +const BARE = `${OBJ}.default`; +const bareView = () => ({ name: BARE, object: OBJ, viewKind: 'list', isDefault: true, config: config() }); + +/** A view with an authored label, which the `zh-CN` catalog translates. */ +const NAMED = `${OBJ}.named`; +const namedView = () => ({ name: NAMED, object: OBJ, label: 'All gates', viewKind: 'list', config: config() }); + +/** A view with NO label, which the `zh-CN` catalog nevertheless translates. */ +const TAGGED = `${OBJ}.tagged`; +const taggedView = () => ({ name: TAGGED, object: OBJ, viewKind: 'list', config: config() }); + +/** A view whose AUTHORED label happens to equal its own name. */ +const SELF = `${OBJ}.self`; +const selfView = () => ({ name: SELF, object: OBJ, label: SELF, viewKind: 'list', config: config() }); + +const VIEWS = () => [bareView(), namedView(), taggedView(), selfView()]; + +const BUNDLE: Record = { + en: { objects: { [OBJ]: { label: 'QA Gate', _views: {} } } }, + 'zh-CN': { + objects: { + [OBJ]: { + label: '质检闸门', + _views: { named: { label: '全部闸门' }, tagged: { label: '已标记' } }, + }, + }, + }, +}; + +const i18nService = { + getLocales: () => Object.keys(BUNDLE), + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +/** A deployment that registers no catalog at all: no bundle, a default locale. */ +const bundlelessI18n = { + getLocales: () => [] as string[], + getTranslations: () => undefined, + getDefaultLocale: () => 'en', +}; + +/** + * What each view is served, per locale. `undefined` = the served document + * carries NO `label` key at all. + */ +const EXPECTED: Record> = { + en: { [BARE]: undefined, [NAMED]: 'All gates', [TAGGED]: undefined, [SELF]: SELF }, + 'zh-CN': { [BARE]: undefined, [NAMED]: '全部闸门', [TAGGED]: '已标记', [SELF]: SELF }, +}; + +const LOCALES = ['en', 'zh-CN'] as const; + +/** One row per (locale, view), so a label-less row and an authored or translated row fail apart. */ +const CELLS = LOCALES.flatMap((locale) => [BARE, NAMED, TAGGED, SELF].map((name) => ({ + locale, + name, + expected: EXPECTED[locale][name] === undefined ? 'no label' : JSON.stringify(EXPECTED[locale][name]), +}))); + +/** The served `label`, asserting the key is absent where nothing was authored or translated. */ +function expectServedLabel(doc: any, name: string, locale: string): void { + const expected = EXPECTED[locale][name]; + expect(doc, `${name} ${locale}: served`).toBeTruthy(); + if (expected === undefined) { + expect('label' in doc, `${name} ${locale}: no label key (served ${JSON.stringify(doc.label)})`).toBe(false); + } else { + expect(doc.label, `${name} ${locale}`).toBe(expected); + } +} + +// --------------------------------------------------------------------------- +// §1 — the standalone server's routes, over a REAL registry and protocol +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * Seed each view as the PUBLISHED row a runtime save stores + * (`state: 'active'`, environment-wide since ADR-0131 D6) and mount the REST + * routes over the real protocol. + */ +function makeHost(i18n: unknown = i18nService) { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + registry.registerItem('object', { + name: OBJ, + label: 'QA Gate', + pluralLabel: 'QA Gates', + fields: { name: { type: 'text', label: 'Name' }, kind: { type: 'text', label: 'Kind' } }, + }, 'name', PKG); + + const rows = VIEWS().map((view, i) => ({ + id: `r_${i}`, + type: 'view', + name: view.name, + package_id: null, + organization_id: null, + state: 'active', + metadata: JSON.stringify(view), + })); + const matches = (r: Record, w: Record) => + Object.entries(w ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (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, + // i18nServiceProvider — the 14th constructor argument. + async () => i18n as never, + ); + (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = async () => ({ + userId: 'u_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }); + rest.registerRoutes(); + const routes = rest.getRouteManager(); + + const run = async (path: string, params: Record, headers: Record) => { + 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, + json: (b: unknown) => { body = b; }, + send: (b: unknown) => { body = b; }, + } as unknown as Parameters[1]; + await entry.handler( + { params, query: {}, body: {}, headers, 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; + }; + const lang = (locale?: string): Record => (locale ? { 'accept-language': locale } : {}); + + return { + /** `GET /meta/view/:name` → the served document. */ + item: async (name: string, locale?: string) => + (await run('/api/v1/meta/:type/:name', { type: 'view', name }, lang(locale)))?.item, + /** `GET /meta/view` → the served page. */ + list: async (locale?: string): Promise => { + const body = await run('/api/v1/meta/:type', { type: 'view' }, lang(locale)); + return Array.isArray(body) ? body : body?.items ?? []; + }, + }; +} + +describe('#23967 §1 the standalone routes — GET /meta/view/NAME and GET /meta/view', () => { + it.each(CELLS)('GET /meta/view/$name, Accept-Language $locale: served $expected', async ({ locale, name }) => { + expectServedLabel(await makeHost().item(name, locale), name, locale); + }); + + it.each(CELLS)('GET /meta/view, Accept-Language $locale: $name served $expected', async ({ locale, name }) => { + const page = await makeHost().list(locale); + expectServedLabel(page.find((d) => d?.name === name), name, locale); + }); + + it('the card\'s read: GET /meta/view/recheck_gate.default carries no `label` equal to its name, with no Accept-Language and no catalog', async () => { + // The deployment's default locale resolves, so the read still translates; nothing is invented. + const host = makeHost(bundlelessI18n); + const item = await host.item(BARE); + expect(item.name).toBe(BARE); + expect(item.label).not.toBe(BARE); + expect('label' in item).toBe(false); + const listed = (await host.list()).find((d) => d?.name === BARE); + expect('label' in listed).toBe(false); + }); + + it('…while the authored label on the same deployment is served as authored, on both reads', async () => { + const host = makeHost(bundlelessI18n); + expect((await host.item(NAMED)).label).toBe('All gates'); + expect((await host.list()).find((d) => d?.name === NAMED)?.label).toBe('All gates'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the functions the runtime dispatcher's `/meta` domain calls +// --------------------------------------------------------------------------- + +function sources(locale: string | undefined, i18n: unknown = i18nService): MetaListTranslationSources { + return { + resolveI18nService: async () => i18n, + resolveProtocol: async () => ({ getPackagedViewBase: () => undefined }), + requestLocale: () => locale, + }; +} + +describe('#23967 §2 translateMetaEnvelope / translateMetaList — the dispatcher\'s item and list translation', () => { + it.each(CELLS)('translateMetaEnvelope, $locale: $name served $expected under `item`', async ({ locale, name }) => { + const view = VIEWS().find((v) => v.name === name)!; + const body = await translateMetaEnvelope(sources(locale), 'view', { type: 'view', name }, view); + expect(body.name).toBe(name); + expectServedLabel(body.item, name, locale); + }); + + it.each(CELLS)('translateMetaList, $locale: $name served $expected, in a bare array and in the `{ type, items }` envelope', async ({ locale, name }) => { + const bare = (await translateMetaList(sources(locale), 'view', VIEWS())) as any[]; + const enveloped = (await translateMetaList(sources(locale), 'view', { type: 'view', items: VIEWS() })) as any; + expect(enveloped.type).toBe('view'); + expectServedLabel(bare.find((d) => d.name === name), name, locale); + expectServedLabel((enveloped.items as any[]).find((d) => d.name === name), name, locale); + }); + + it('with no catalog registered, a label-less view is served none on both reads', async () => { + const item = (await translateMetaDocument(sources('en', bundlelessI18n), 'view', bareView())) as any; + expect('label' in item).toBe(false); + const [listed] = (await translateMetaList(sources('en', bundlelessI18n), 'view', [bareView()])) as any[]; + expect('label' in listed).toBe(false); + }); + + it('keeps everything else the view carries, and does not mutate the served document', async () => { + const view = bareView(); + const item = (await translateMetaDocument(sources('zh-CN'), 'view', view)) as any; + expect(item).toEqual(bareView()); + expect(view).toEqual(bareView()); + const [listed] = (await translateMetaList(sources('zh-CN'), 'view', [view])) as any[]; + expect(listed).toEqual(bareView()); + }); + + it('a label-less view served with no locale resolved is returned as stored', async () => { + const view = bareView(); + expect(await translateMetaDocument(sources(undefined), 'view', view)).toBe(view); + const page = [view]; + expect(await translateMetaList(sources(undefined), 'view', page)).toBe(page); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — CONTROL: the container read answers what it answered +// --------------------------------------------------------------------------- + +describe('#23967 §3 CONTROL — the view container read is unchanged', () => { + const grid = (label?: string, columns: string[] = ['name']) => ({ + type: 'grid', + ...(label !== undefined ? { label } : {}), + data: { provider: 'object', object: OBJ }, + columns, + }); + + it('translates each slot it has a string for, and invents no label for the container or a slot without one', async () => { + // The default `list` differs from every named list, so the composer keeps it its own item + // (`recheck_gate.default`), which the catalog does not carry. + const ownList = () => grid(undefined, ['name', 'kind']); + const container = { object: OBJ, list: ownList(), listViews: { named: grid('All gates'), tagged: grid() } }; + const out = (await translateMetaDocument(sources('zh-CN'), 'view', container)) as any; + expect(out).toEqual({ + object: OBJ, + list: ownList(), + listViews: { named: grid('全部闸门'), tagged: grid('已标记') }, + }); + expect(container.listViews.named.label).toBe('All gates'); + }); + + it('returns the SAME container when no catalog string applies', async () => { + const container = { object: OBJ, list: grid() }; + expect(await translateMetaDocument(sources('en'), 'view', container)).toBe(container); + }); +}); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 22f7bb18869..8952c2ceafe 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3831,6 +3831,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/meta-view-label-not-invented.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/public-form-routes.stored-row.test.ts", "verb": "delete",