From b2803ff2c350b36a5c83411c247224a15fa39c99 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:02:13 +0000 Subject: [PATCH 1/6] fix(rest): the /meta item and list reads hand a dashboard and a view their packaged base, so a published org overlay beats the packaged catalog packagedObjectBaseOf becomes the one per-type packaged-base resolver: object answers getPackagedObjectBase as before, dashboard answers getPackagedDashboardBase and view getPackagedViewBase (by the served item's qualified registry name). translateMetaDocument and translateMetaList resolve the protocol for every type the table covers. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/rest/src/meta-item-read-gate.ts | 79 +++++++++++++++++++----- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 662d8718712..67360fbe9f5 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -1882,20 +1882,61 @@ export function metaTranslateOptions( } /** - * [#8284] The packaged (code-layer) base declaration of an OBJECT, for - * `translateObject`'s `packagedBase` — `undefined` on every uncertainty, which - * the spec-side rule reads as "no baseline known". Feature-detected on the - * protocol (`getPackagedObjectBase` is a server-only extension). - * `RestServer.packagedObjectBase`, whose docblock carries the rule, delegates - * here. + * [#8284 · #20730] Which protocol accessor answers the packaged (code-layer) + * base of each metadata type — the ONE table {@link packagedObjectBaseOf} + * reads, and the one membership test that decides whether a translation + * resolves the protocol at all. A type absent here has no packaged base the + * serving layer can hand the translator, and its catalog applies unchanged. + * + * The rule the base feeds is ADR-0029 D9.2a's — an explicit override beats a + * packaged default, decided by comparison against the PACKAGED declaration + * (`@objectstack/spec/system` owns the comparison; this table only hands it + * the value it cannot see). The types it covers are exactly the translatable + * types whose served document can diverge from what the package shipped: + * + * - `object` — a code `objectExtensions` scalar or a tenant rename folds onto + * the owner's declaration (`getPackagedObjectBase`, pre-fold); + * - `dashboard` and `view` — ADR-0126 Regime O: a packaged item is + * overlay-editable, and a published org overlay is what every read serves + * (`getPackagedDashboardBase`, `getPackagedViewBase`). 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. + * + * The other translatable types (`action`, `app`, `dataset`, `page`) are + * Regime B — a packaged item is not customizable — so the served document of + * a packaged item is the packaged one and there is nothing to compare. + * + * ⛔ One table, never a second resolver beside it: a type that gains a + * packaged-base accessor on the protocol adds its row here. + */ +const PACKAGED_BASE_ACCESSORS: ReadonlyMap = new Map([ + ['object', 'getPackagedObjectBase'], + ['dashboard', 'getPackagedDashboardBase'], + ['view', 'getPackagedViewBase'], +]); + +/** + * [#8284 · #20730] The packaged (code-layer) base declaration of one served + * metadata document, for `translateMetadataDocument`'s `packagedBase` — the + * per-type resolver over {@link PACKAGED_BASE_ACCESSORS}: an `object` answers + * `getPackagedObjectBase(name)`, a `dashboard` `getPackagedDashboardBase(name)` + * and a `view` `getPackagedViewBase(name)`. + * + * `undefined` on every uncertainty — a type with no row, an empty name, a + * protocol without the accessor (they are server-only extensions, so + * feature-detected), an accessor that throws — which the spec-side rule reads + * as "no baseline known" and answers with the catalog, as before the base + * existed. `RestServer.packagedObjectBase`, whose docblock carries the object + * rule, delegates here. */ export function packagedObjectBaseOf(protocol: unknown, type: string, name: unknown): unknown { - if (type !== 'object') return undefined; + const accessor = PACKAGED_BASE_ACCESSORS.get(type); + if (accessor === undefined) return undefined; if (typeof name !== 'string' || name === '') return undefined; const p: any = protocol; - if (!p || typeof p.getPackagedObjectBase !== 'function') return undefined; + if (!p || typeof p[accessor] !== 'function') return undefined; try { - return p.getPackagedObjectBase(name); + return p[accessor](name); } catch { return undefined; } @@ -1905,7 +1946,10 @@ export function packagedObjectBaseOf(protocol: unknown, type: string, name: unkn export interface MetaListTranslationSources { /** This request's i18n service, or `undefined` when the deployment has none. */ resolveI18nService(): Promise; - /** This request's protocol, for the packaged object base; `undefined` when unreachable. */ + /** + * This request's protocol, for the packaged base of an object, dashboard + * or view ({@link packagedObjectBaseOf}); `undefined` when unreachable. + */ resolveProtocol(): Promise; /** * This request's locale ({@link metaRequestLocale} over the transport's own @@ -1945,9 +1989,11 @@ export async function translateMetaList( const locale = sources.requestLocale(i18n); if (!locale) return items; const { translateMetadataDocument } = await import('@objectstack/spec/system'); - // [#8284] One protocol resolution for the whole page; the lookup itself is - // a synchronous in-memory registry read per element. - const p = metaType === 'object' ? await sources.resolveProtocol() : undefined; + // [#8284 · #20730] One protocol resolution for the whole page, for every + // type with a packaged base (object, dashboard, view); 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; // `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, { @@ -1967,8 +2013,9 @@ export async function translateMetaList( * Takes the DOCUMENT, never the `getMetaItem` envelope (#5563): nav and field * labels live on the document. A missing bundle is not a bail-out (the * built-in fallbacks still apply); no locale is. The i18n service is resolved - * only for a translatable type, and the protocol only for an `object` (#8284, - * the packaged base). + * only for a translatable type, and the protocol only for a type with a + * packaged base — an object, a dashboard or a view (#8284, #20730: + * {@link packagedObjectBaseOf}). * * Moved here, unchanged, from `RestServer.translateMetaItem` (which now * delegates), so the runtime dispatcher's item read translates with the same @@ -1986,7 +2033,7 @@ export async function translateMetaDocument( const locale = sources.requestLocale(i18n); if (!locale) return document; const { translateMetadataDocument } = await import('@objectstack/spec/system'); - const packagedBase = metaType === 'object' + const packagedBase = PACKAGED_BASE_ACCESSORS.has(metaType) ? packagedObjectBaseOf(await sources.resolveProtocol(), metaType, (document as any)?.name) : undefined; return translateMetadataDocument(metaType, document as any, bundle, { From 87f1867ea993ecd2d093adaabb5e17ad1bf19f68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:04:42 +0000 Subject: [PATCH 2/6] test(rest): pin the dashboard and view packaged base through both /meta reads Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...hboard-view-i18n-explicit-override.test.ts | 359 ++++++++++++++++++ packages/rest/src/meta-item-read-gate.ts | 10 +- 2 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts 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 new file mode 100644 index 00000000000..d28e839df98 --- /dev/null +++ b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20730 — the `/meta` item and list reads hand a DASHBOARD and a VIEW their + * packaged base, so a published org overlay beats the packaged catalog. The + * dashboard and view twin of `meta-object-i18n-explicit-override.test.ts`. + * + * The RULE lives in `@objectstack/spec/system` (`translateDashboard`, + * `translateView`: the catalog applies only while the served string still + * equals the packaged one — ADR-0029 D9.2a), and the BASE lives on the + * protocol (`getPackagedDashboardBase`, `getPackagedViewBase`). Both halves + * were on `main` and neither changed a served answer, because the read + * boundary handed the translator a base for objects only + * (`packagedObjectBaseOf`). What is pinned here is that plumbing, through the + * ROUTES, over the REAL protocol and a REAL registry: the packaged items are + * registered the way the boot registers them and the org overlay rows are + * seeded the way a published overlay stores them, so no write verb is doubled. + * + * Measured before the fix (a booted showcase, admin and member of one org): an + * overlay on `system_overview` published, `?layers=true` reported it + * effective, and both reads kept serving `Total Users` / `用户总数`; the same for + * a view overlay on `showcase_task.in_progress` read in `zh-CN` (`进行中`). + * + * Covered, per the triage's list: after an overlay and a publish, the item read + * and the list read serve the edit, for an admin and for a member, in `en` and + * `zh-CN`; the unedited widget / view stays translated; a reset (no overlay + * row) restores the shipped body, translated; a dashboard whose catalog + * carries no widget titles (the showcase control) serves the edit unchanged. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { expandViewContainer } from '@objectstack/spec/ui'; +import { RestServer } from './rest-server.js'; +import { packagedObjectBaseOf } from './meta-item-read-gate.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ORG = 'org_acme'; +const AUTH_PKG = 'com.objectstack.plugin-auth'; +const SHOWCASE_PKG = 'com.example.showcase'; + +const DASH = 'system_overview'; +const CONTROL_DASH = 'showcase_overview'; +const OBJ = 'showcase_task'; +const VIEW = 'showcase_task.in_progress'; +const OTHER_VIEW = 'showcase_task.urgent'; + +const SHIPPED_TITLE = 'Total Users'; +const EDITED_TITLE = 'Total Users (edited-20730)'; +const SHIPPED_LABEL = 'In Progress'; +const EDITED_LABEL = 'In Progress (edited-20730)'; + +/** The dashboard as the code package ships it. */ +const packagedDashboard = () => ({ + name: DASH, + label: 'System Overview', + columns: 12, + widgets: [ + { id: 'widget_total_users', type: 'metric', title: SHIPPED_TITLE, object: 'sys_user', layout: { x: 0, y: 0, w: 3, h: 2 } }, + { id: 'widget_organizations', type: 'metric', title: 'Organizations', object: 'sys_organization', layout: { x: 3, y: 0, w: 3, h: 2 } }, + ], +}); + +/** The control: a packaged dashboard whose catalog ships no widget titles. */ +const packagedControlDashboard = () => ({ + name: CONTROL_DASH, + label: 'Showcase Overview', + columns: 12, + widgets: [ + { id: 'open_tasks', type: 'metric', title: 'Open Tasks', object: OBJ, layout: { x: 0, y: 0, w: 3, h: 2 } }, + ], +}); + +const withWidgetTitle = (body: ReturnType | ReturnType, id: string, title: string) => + ({ ...body, widgets: body.widgets.map((w) => (w.id === id ? { ...w, title } : w)) }); + +const listView = (label: string) => ({ + label, + type: 'grid' as const, + data: { provider: 'object' as const, object: OBJ }, + columns: [{ field: 'title' }], +}); + +/** The `defineView` container as the code package ships it. */ +const taskContainer = () => ({ + listViews: { in_progress: listView(SHIPPED_LABEL), urgent: listView('Urgent') }, +}); + +/** The packaged view item as the boot registers it (`expandViewContainer`), by its qualified name. */ +const packagedViewItem = (name: string) => { + const item = expandViewContainer(OBJ, taskContainer()).find((v) => v.name === name); + if (!item) throw new Error(`fixture missing ${name}`); + return item; +}; + +/** + * The packaged catalog: `en` repeats the shipped strings (what `i18n:extract` + * writes, and what `platform-objects` ships for `system_overview`), `zh-CN` + * translates them. The defect showed in BOTH: an `en` reader got the shipped + * English back over the edit. The control dashboard has a label entry only. + */ +const BUNDLE: Record = { + en: { + dashboards: { + [DASH]: { + widgets: { + widget_total_users: { title: SHIPPED_TITLE }, + widget_organizations: { title: 'Organizations' }, + }, + }, + [CONTROL_DASH]: { label: 'Showcase Overview' }, + }, + objects: { [OBJ]: { _views: { in_progress: { label: SHIPPED_LABEL }, urgent: { label: 'Urgent' } } } }, + }, + 'zh-CN': { + dashboards: { + [DASH]: { + widgets: { + widget_total_users: { title: '用户总数' }, + widget_organizations: { title: '组织' }, + }, + }, + [CONTROL_DASH]: { label: '展示概览' }, + }, + objects: { [OBJ]: { _views: { in_progress: { label: '进行中' }, urgent: { label: '紧急' } } } }, + }, +}; + +const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +/** An org-bound admin and a member of the same org — `tenantId` is the vetted organization. */ +const CALLERS = { + admin: { userId: 'u_admin', tenantId: ORG, systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }, + member: { userId: 'u_member', tenantId: ORG, systemPermissions: [] }, +} as const; +type Who = keyof typeof CALLERS; + +const LOCALES = ['en', 'zh-CN'] as const; + +// --------------------------------------------------------------------------- +// The host: REAL registry, REAL protocol, the REST routes +// --------------------------------------------------------------------------- + +type OverlayRow = { type: 'dashboard' | 'view'; name: string; body: Record }; + +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), + }; +} + +/** + * Register the packaged items the way the boot does and seed each overlay as + * the PUBLISHED org row its write stores (`package_id: null`, the org, + * `state: 'active'`). `overlays: []` is the state after a reset. + */ +function makeHost(overlays: OverlayRow[], who: Who) { + const registry = new SchemaRegistry({ multiTenant: false }); + registry.logLevel = 'silent'; + registry.registerItem('dashboard', packagedDashboard(), 'name', AUTH_PKG); + registry.registerItem('dashboard', packagedControlDashboard(), 'name', SHOWCASE_PKG); + registry.registerItem('view', { name: OBJ, ...taskContainer() }, 'name', SHOWCASE_PKG); + for (const item of expandViewContainer(OBJ, taskContainer())) registry.registerItem('view', item, 'name', SHOWCASE_PKG); + + const rows = overlays.map((o, i) => ({ + id: `r_${i}`, + type: o.type, + name: o.name, + package_id: null, + organization_id: ORG, + state: 'active', + metadata: JSON.stringify(o.body), + })); + 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 () => i18nService 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, + 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)}`).toBe(200); + return body as any; + }; + + return { + /** `GET /meta/:type/:name` → the served document. */ + item: async (type: string, name: string, locale: string) => + (await run('/api/v1/meta/:type/:name', { type, name }, locale))?.item, + /** `GET /meta/:type` → the served document named `name` from the page. */ + 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); + }, + }; +} + +const titleOf = (doc: any, id: string) => (doc?.widgets as any[] | undefined)?.find((w) => w?.id === id)?.title; + +const EDITED_DASHBOARD: OverlayRow = { type: 'dashboard', name: DASH, body: withWidgetTitle(packagedDashboard(), 'widget_total_users', EDITED_TITLE) }; +const EDITED_VIEW: OverlayRow = { type: 'view', name: VIEW, body: { ...packagedViewItem(VIEW), label: EDITED_LABEL } }; + +const READS = ['item', 'listed'] as const; +const cells = () => (Object.keys(CALLERS) as Who[]).flatMap((who) => + LOCALES.flatMap((locale) => READS.map((read) => ({ who, locale, read })))); + +// --------------------------------------------------------------------------- +// §1 — the resolver: one table, three accessors +// --------------------------------------------------------------------------- + +describe('#20730 §1 packagedObjectBaseOf — one per-type packaged-base resolver', () => { + const protocol = () => ({ + getPackagedObjectBase: vi.fn((name: string) => ({ kind: 'object', name })), + getPackagedDashboardBase: vi.fn((name: string) => ({ kind: 'dashboard', name })), + getPackagedViewBase: vi.fn((name: string) => ({ kind: 'view', name })), + }); + + it('asks each type its own accessor, handing the served name through unchanged', () => { + const p = protocol(); + expect(packagedObjectBaseOf(p, 'object', 'showcase_account')).toEqual({ kind: 'object', name: 'showcase_account' }); + expect(packagedObjectBaseOf(p, 'dashboard', DASH)).toEqual({ kind: 'dashboard', name: DASH }); + // The qualified registry name — never the bare `in_progress` the catalog uses. + expect(packagedObjectBaseOf(p, 'view', VIEW)).toEqual({ kind: 'view', name: VIEW }); + expect(p.getPackagedObjectBase).toHaveBeenCalledTimes(1); + expect(p.getPackagedDashboardBase).toHaveBeenCalledTimes(1); + expect(p.getPackagedViewBase).toHaveBeenCalledTimes(1); + }); + + it('asks nothing for a type with no packaged base', () => { + const p = protocol(); + for (const type of ['action', 'app', 'dataset', 'page', 'report', 'toString', 'constructor']) { + expect(packagedObjectBaseOf(p, type, 'x'), type).toBeUndefined(); + } + expect(p.getPackagedObjectBase).not.toHaveBeenCalled(); + expect(p.getPackagedDashboardBase).not.toHaveBeenCalled(); + expect(p.getPackagedViewBase).not.toHaveBeenCalled(); + }); + + it('answers undefined for an empty name, a protocol without the accessor, and an accessor that throws', () => { + expect(packagedObjectBaseOf(protocol(), 'dashboard', '')).toBeUndefined(); + expect(packagedObjectBaseOf(protocol(), 'view', undefined)).toBeUndefined(); + expect(packagedObjectBaseOf({ getPackagedObjectBase: () => ({}) }, 'dashboard', DASH)).toBeUndefined(); + expect(packagedObjectBaseOf(undefined, 'view', VIEW)).toBeUndefined(); + const throwing = { getPackagedViewBase: () => { throw new Error('registry read failed'); } }; + expect(packagedObjectBaseOf(throwing, 'view', VIEW)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — a published dashboard overlay beats the packaged catalog +// --------------------------------------------------------------------------- + +describe('#20730 §2 dashboard — a published org overlay is what both /meta reads serve', () => { + it.each(cells())('$read read, $who, $locale: the edited widget serves the edit', async ({ who, locale, read }) => { + const host = makeHost([EDITED_DASHBOARD], who); + expect(titleOf(await host[read]('dashboard', DASH, locale), 'widget_total_users')).toBe(EDITED_TITLE); + }); + + it.each(cells())('$read read, $who, $locale: the unedited widget stays translated', async ({ who, locale, read }) => { + const host = makeHost([EDITED_DASHBOARD], who); + const expected = locale === 'zh-CN' ? '组织' : 'Organizations'; + expect(titleOf(await host[read]('dashboard', DASH, locale), 'widget_organizations')).toBe(expected); + }); + + it.each(cells())('$read read, $who, $locale: after a reset the shipped body is served, translated', async ({ who, locale, read }) => { + const host = makeHost([], who); + const expected = locale === 'zh-CN' ? '用户总数' : SHIPPED_TITLE; + expect(titleOf(await host[read]('dashboard', DASH, locale), 'widget_total_users')).toBe(expected); + }); + + it('CONTROL — the showcase dashboard, whose catalog ships no widget titles, serves an overlay edit on both reads, as before', async () => { + const overlay: OverlayRow = { + type: 'dashboard', + name: CONTROL_DASH, + body: withWidgetTitle(packagedControlDashboard(), 'open_tasks', 'Open Tasks (edited-20730)'), + }; + for (const who of Object.keys(CALLERS) as Who[]) { + const host = makeHost([overlay], who); + for (const locale of LOCALES) { + for (const read of READS) { + const doc = await host[read]('dashboard', CONTROL_DASH, locale); + expect(titleOf(doc, 'open_tasks'), `${read} ${who} ${locale}`).toBe('Open Tasks (edited-20730)'); + // Its unedited label still translates. + expect(doc?.label, `${read} ${who} ${locale}`).toBe(locale === 'zh-CN' ? '展示概览' : 'Showcase Overview'); + } + } + } + }); +}); + +// --------------------------------------------------------------------------- +// §3 — a published view overlay beats the packaged catalog +// --------------------------------------------------------------------------- + +describe('#20730 §3 view — a published org overlay on a packaged view is what both /meta reads serve', () => { + it.each(cells())('$read read, $who, $locale: the edited view serves the edit', async ({ who, locale, read }) => { + const host = makeHost([EDITED_VIEW], who); + expect((await host[read]('view', VIEW, locale))?.label).toBe(EDITED_LABEL); + }); + + it.each(cells())('$read read, $who, $locale: an unedited view stays translated', async ({ who, locale, read }) => { + const host = makeHost([EDITED_VIEW], who); + expect((await host[read]('view', OTHER_VIEW, locale))?.label).toBe(locale === 'zh-CN' ? '紧急' : 'Urgent'); + }); + + it.each(cells())('$read read, $who, $locale: after a reset the shipped label is served, translated', async ({ who, locale, read }) => { + const host = makeHost([], who); + expect((await host[read]('view', VIEW, locale))?.label).toBe(locale === 'zh-CN' ? '进行中' : SHIPPED_LABEL); + }); +}); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 67360fbe9f5..38a35644da6 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -1891,8 +1891,7 @@ export function metaTranslateOptions( * The rule the base feeds is ADR-0029 D9.2a's — an explicit override beats a * packaged default, decided by comparison against the PACKAGED declaration * (`@objectstack/spec/system` owns the comparison; this table only hands it - * the value it cannot see). The types it covers are exactly the translatable - * types whose served document can diverge from what the package shipped: + * the value it cannot see). The rows, one per protocol accessor: * * - `object` — a code `objectExtensions` scalar or a tenant rename folds onto * the owner's declaration (`getPackagedObjectBase`, pre-fold); @@ -1902,9 +1901,10 @@ export function metaTranslateOptions( * by the served item's registry name, the qualified `.` — * never the bare key the catalog addresses it by under its object. * - * The other translatable types (`action`, `app`, `dataset`, `page`) are - * Regime B — a packaged item is not customizable — so the served document of - * a packaged item is the packaged one and there is nothing to compare. + * 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. * * ⛔ One table, never a second resolver beside it: a type that gains a * packaged-base accessor on the protocol adds its row here. From e830f24cbb017dd90a56061c8b1b6607fe95ab0c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:20:13 +0000 Subject: [PATCH 3/6] docs(ui): state the dashboard and view rule, an org's edit beats the packaged catalog; changeset for @objectstack/rest Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...20730-meta-packaged-base-dashboard-view.md | 18 +++++++++++++ content/docs/ui/translations.mdx | 27 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .changeset/20730-meta-packaged-base-dashboard-view.md diff --git a/.changeset/20730-meta-packaged-base-dashboard-view.md b/.changeset/20730-meta-packaged-base-dashboard-view.md new file mode 100644 index 00000000000..bbf2021f2d8 --- /dev/null +++ b/.changeset/20730-meta-packaged-base-dashboard-view.md @@ -0,0 +1,18 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): an org's published edit to a packaged dashboard or view is what the `/meta` item and list reads serve, in every locale, instead of the packaged translation of the string it replaced + +An organization may edit a packaged dashboard or view in place and publish the edit. The metadata protocol's reads returned the edit, and `?layers=true` reported it as effective, but `GET /api/v1/meta/dashboard/:name`, `GET /api/v1/meta/dashboard`, `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/view` served the bundle's translation of the string the package shipped. For example, a widget retitled `Total Users (edited)` on the platform's `system_overview` dashboard was served as `Total Users` to an `en` reader and as `用户总数` to a `zh-CN` reader. The console draws a dashboard from the list read, so the edit never appeared on the board. + +The translators in `@objectstack/spec/system` already let an edited string win over the bundle when they are handed the item as the package shipped it, and the metadata protocol already answers that item (`getPackagedDashboardBase`, `getPackagedViewBase`). The `/meta` reads handed it over for objects only. They now hand it over for dashboards and views too, on both transports that serve `/meta` (the REST server and the runtime's HTTP dispatcher). A view is looked up by its full `.` name. + +What a reader sees now: + +- An edited string is served as written, in every locale. +- A widget or view the org left alone is still translated. +- Resetting the overlay brings back the shipped string and its translation. +- A dashboard or view with no org edit is served exactly as before. + +Nothing to migrate: no key, export or route changed. diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 26ec9d97c72..dd4b8ac064f 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -174,6 +174,33 @@ Resolved labels are served straight from the REST metadata endpoints (the locale is part of the ETag), so the Console and any SDUI client get translated metadata without doing lookups themselves. +### An edit beats the packaged catalog + +A package's bundle translates the strings that package shipped, so it applies +only to a string the served metadata still carries unchanged. Dashboards and +views are the translated types an organization can edit in place: once an +org's edit to a packaged dashboard or view is published, the metadata reads +serve each edited string as written, in every locale. Each string is compared +with the same string in the item as the package shipped it: + +| Packaged type | Strings compared, and how each is matched | +|:---|:---| +| Dashboard | its `label` and `description`; each widget's `title`, `description` and sub-caption (`subCaption`), matched by widget `id`; each global filter's `label` and static option labels, matched by the filter key and the option `value` | +| View | its `label` and `description`; each bulk action's `label`, `confirmText` and `confirmLabel`, and each of its params' `label`, `help` and `placeholder`, matched by `name` | + +- **Only the edited strings move.** A widget the org left alone on an edited + dashboard, or a view nobody edited, is still translated. +- **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. +- **Resetting the overlay restores the shipped string and its translation.** + +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, +for one) is served as written. + ## Organizing the files How you lay out translation source files is an authoring convention — your From 8ee4569fa3fde6efdbb2964fb0abde1d9becc85c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:26:43 +0000 Subject: [PATCH 4/6] test(rest): pin the new findOne double in the engine-double ledger Written by check-engine-double-contract --write for the dashboard and view packaged-base pin file. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d764283efdd..322e7f6d6d9 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3396,6 +3396,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/meta-object-extension-agreement.test.ts", "verb": "findOne", From d6da98fb234ac2fb766e32929467f14ee4c806bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:45:35 +0000 Subject: [PATCH 5/6] docs(changeset): state where the fix lives rather than a per-transport claim Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20730-meta-packaged-base-dashboard-view.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20730-meta-packaged-base-dashboard-view.md b/.changeset/20730-meta-packaged-base-dashboard-view.md index bbf2021f2d8..c22243626c4 100644 --- a/.changeset/20730-meta-packaged-base-dashboard-view.md +++ b/.changeset/20730-meta-packaged-base-dashboard-view.md @@ -6,7 +6,7 @@ fix(rest): an org's published edit to a packaged dashboard or view is what the ` An organization may edit a packaged dashboard or view in place and publish the edit. The metadata protocol's reads returned the edit, and `?layers=true` reported it as effective, but `GET /api/v1/meta/dashboard/:name`, `GET /api/v1/meta/dashboard`, `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/view` served the bundle's translation of the string the package shipped. For example, a widget retitled `Total Users (edited)` on the platform's `system_overview` dashboard was served as `Total Users` to an `en` reader and as `用户总数` to a `zh-CN` reader. The console draws a dashboard from the list read, so the edit never appeared on the board. -The translators in `@objectstack/spec/system` already let an edited string win over the bundle when they are handed the item as the package shipped it, and the metadata protocol already answers that item (`getPackagedDashboardBase`, `getPackagedViewBase`). The `/meta` reads handed it over for objects only. They now hand it over for dashboards and views too, on both transports that serve `/meta` (the REST server and the runtime's HTTP dispatcher). A view is looked up by its full `.` name. +The translators in `@objectstack/spec/system` already let an edited string win over the bundle when they are handed the item as the package shipped it, and the metadata protocol already answers that item (`getPackagedDashboardBase`, `getPackagedViewBase`). The `/meta` reads handed it over for objects only. They now hand it over for dashboards and views too. The change is in the translation step that both `/meta` transports share, the REST server's routes and the runtime's HTTP dispatcher. A view is looked up by its full `.` name. What a reader sees now: From 2b8df02e7aca4b62cbf44ea8860d6f8a638916aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:46:00 +0000 Subject: [PATCH 6/6] docs(changeset): say the console still draws the packaged translation, as measured Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20730-meta-packaged-base-dashboard-view.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/20730-meta-packaged-base-dashboard-view.md b/.changeset/20730-meta-packaged-base-dashboard-view.md index c22243626c4..d3d3735ae28 100644 --- a/.changeset/20730-meta-packaged-base-dashboard-view.md +++ b/.changeset/20730-meta-packaged-base-dashboard-view.md @@ -4,7 +4,7 @@ fix(rest): an org's published edit to a packaged dashboard or view is what the `/meta` item and list reads serve, in every locale, instead of the packaged translation of the string it replaced -An organization may edit a packaged dashboard or view in place and publish the edit. The metadata protocol's reads returned the edit, and `?layers=true` reported it as effective, but `GET /api/v1/meta/dashboard/:name`, `GET /api/v1/meta/dashboard`, `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/view` served the bundle's translation of the string the package shipped. For example, a widget retitled `Total Users (edited)` on the platform's `system_overview` dashboard was served as `Total Users` to an `en` reader and as `用户总数` to a `zh-CN` reader. The console draws a dashboard from the list read, so the edit never appeared on the board. +An organization may edit a packaged dashboard or view in place and publish the edit. The metadata protocol's reads returned the edit, and `?layers=true` reported it as effective, but `GET /api/v1/meta/dashboard/:name`, `GET /api/v1/meta/dashboard`, `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/view` served the bundle's translation of the string the package shipped. For example, a widget retitled `Total Users (edited)` on the platform's `system_overview` dashboard was served as `Total Users` to an `en` reader and as `用户总数` to a `zh-CN` reader. The translators in `@objectstack/spec/system` already let an edited string win over the bundle when they are handed the item as the package shipped it, and the metadata protocol already answers that item (`getPackagedDashboardBase`, `getPackagedViewBase`). The `/meta` reads handed it over for objects only. They now hand it over for dashboards and views too. The change is in the translation step that both `/meta` transports share, the REST server's routes and the runtime's HTTP dispatcher. A view is looked up by its full `.` name. @@ -15,4 +15,6 @@ What a reader sees now: - Resetting the overlay brings back the shipped string and its translation. - A dashboard or view with no org edit is served exactly as before. +This fixes what the metadata reads serve, not yet what the console draws. The console built from objectui `db11afd4967c`, this repository's pin when the change was made, looks a dashboard's widget titles and a view's label up in the bundle again in the browser, so it still draws the packaged translation over an edit the server now serves: measured, the `system_overview` board shows `Total Users` / `用户总数` and a `zh-CN` view tab shows `进行中`. + Nothing to migrate: no key, export or route changed.