From 6e6b2325a7dce23562951aef0f9cd26b4ab75675 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 04:12:26 +0000 Subject: [PATCH 1/2] fix(spec): localise the tenant-scope and owning-business-unit injected columns on the /meta read exits Add the two rows the built-in SYSTEM_FIELD_LABELS table was missing (organization_id, owning_business_unit_id) for every locale the table already carries, pin every injected column per shipped locale from the provenance module's own definitions, and keep the tenant-rename guard. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- ...injected-system-column-labels-localised.md | 7 ++ .../spec/src/system/i18n-resolver.test.ts | 116 ++++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 37 +++++- 3 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 .changeset/injected-system-column-labels-localised.md diff --git a/.changeset/injected-system-column-labels-localised.md b/.changeset/injected-system-column-labels-localised.md new file mode 100644 index 0000000000..3aff300a37 --- /dev/null +++ b/.changeset/injected-system-column-labels-localised.md @@ -0,0 +1,7 @@ +--- +"@objectstack/spec": patch +--- + +The tenant-scope and owning-business-unit system columns now render a localised display name on the `/meta` read exits, as the other platform-injected columns already did. + +`translateObject` carries a built-in label table for the columns the platform injects onto every eligible object, applied while a column still carries its injected English default, so a `zh-CN` / `ja-JP` / `es-ES` request never sees the English label on a custom object that ships no translation entries of its own. The table covered `owner_id`, `created_at`, `created_by`, `updated_at` and `updated_by` but not the two remaining injected columns, `organization_id` (`Organization`) and `owning_business_unit_id` (`Owning Business Unit`), so those two leaked English on every locale. Both rows are added, with the wording the platform bundles already use for the same columns on platform objects. The identity-stable column definitions are untouched, no new authorable key is introduced, and a label a tenant or author customised is still never overridden. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index a3094d8d2d..1f1abb7df5 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -2212,6 +2212,122 @@ describe('translateObject system-field label fallback', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// translateObject — EVERY platform-injected column, per shipped locale +// (objectstack#14972) +// ──────────────────────────────────────────────────────────────────────────── + +import { injectedSystemColumnDefs } from '../data/injected-system-column-provenance'; + +describe('translateObject localises every platform-injected column (objectstack#14972)', () => { + // The column definitions come from the provenance module itself — the same + // objects `applySystemFields` spreads at registration and the `/meta` read + // exits serve — so the English defaults this block starts from cannot drift + // from the shipped tables through a retyped label. The document is a custom + // object that ships no translation entries of its own, and the bundle is + // absent: only the built-in table can answer. + const injected = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } }); + const doc = { + name: 'contracts', + label: 'Contract', + fields: { + title: { name: 'title', type: 'text', label: '合同名称' }, + ...(injected as Record), + }, + }; + const SHIPPED_LOCALES = ['en', 'zh-CN', 'ja-JP', 'es-ES'] as const; + const labelsFor = (locale: string): Record => { + const out = translateObject(doc, undefined, { locale, fallbackChain: [locale] }); + const fields = out.fields as Record; + return Object.fromEntries(Object.keys(injected).map((name) => [name, fields[name].label])); + }; + + it('starts from all seven injected columns carrying their shipped English defaults', () => { + expect(Object.keys(injected).sort()).toEqual([ + 'created_at', + 'created_by', + 'organization_id', + 'owner_id', + 'owning_business_unit_id', + 'updated_at', + 'updated_by', + ]); + // An `en` request leaves every label exactly as the definition ships it. + expect(labelsFor('en')).toEqual({ + organization_id: 'Organization', + created_at: 'Created At', + created_by: 'Created By', + updated_at: 'Last Modified At', + updated_by: 'Last Modified By', + owner_id: 'Owner', + owning_business_unit_id: 'Owning Business Unit', + }); + }); + + it('zh-CN: every injected column reads Chinese, in the platform bundles\' wording', () => { + expect(labelsFor('zh-CN')).toEqual({ + organization_id: '组织', + created_at: '创建时间', + created_by: '创建人', + updated_at: '更新时间', + updated_by: '更新人', + owner_id: '所有者', + owning_business_unit_id: '所属业务单元', + }); + }); + + it('ja-JP: every injected column reads Japanese, in the platform bundles\' wording', () => { + expect(labelsFor('ja-JP')).toEqual({ + organization_id: '組織', + created_at: '作成日時', + created_by: '作成者', + updated_at: '更新日時', + updated_by: '更新者', + owner_id: '所有者', + owning_business_unit_id: '所属ビジネスユニット', + }); + }); + + it('es-ES: every injected column reads Spanish, in the platform bundles\' wording', () => { + expect(labelsFor('es-ES')).toEqual({ + organization_id: 'Organización', + created_at: 'Creado el', + created_by: 'Creado por', + updated_at: 'Actualizado el', + updated_by: 'Actualizado por', + owner_id: 'Propietario', + owning_business_unit_id: 'Unidad de negocio propietaria', + }); + }); + + it('a tenant that relabelled the organization column keeps its label on every locale', () => { + // The guard is comparison-based: the built-in row applies only while the + // served label still equals the definition's English default. A label the + // tenant (or the author) wrote is authored data and wins on every locale, + // the `en` request included. + const renamed = { + ...doc, + fields: { + ...doc.fields, + organization_id: { ...(injected.organization_id as Record), label: '所属公司' }, + }, + }; + for (const locale of SHIPPED_LOCALES) { + const out = translateObject(renamed, undefined, { locale, fallbackChain: [locale] }); + expect((out.fields as any).organization_id.label, locale).toBe('所属公司'); + // The untouched columns still localise around it. + expect((out.fields as any).owner_id.label, locale).toBe(labelsFor(locale).owner_id); + } + }); + + it('never mutates the input document or the shipped definitions', () => { + labelsFor('zh-CN'); + expect((doc.fields as any).organization_id.label).toBe('Organization'); + expect((doc.fields as any).owning_business_unit_id.label).toBe('Owning Business Unit'); + expect(injected.organization_id.label).toBe('Organization'); + }); +}); + describe('translateObject inline actions (objectstack#3370)', () => { // The `sys_approval_request` shape: decision actions declared inline on the // object. The plugin ships `_actions` translations for them, but the object diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 1ad0166dd1..0dc4e42c60 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2081,22 +2081,47 @@ function lookupObjectFieldOption( } /** - * Built-in labels for the platform-injected system fields (the ObjectQL - * registry stamps `owner_id` / `created_*` / `updated_*` onto every object - * with English labels). Custom objects carry no per-object translation - * entries for these, so without a fallback every localized surface — list - * headers, export files, import templates — leaks the English default (e.g. - * an otherwise fully-Chinese import template with an `Owner` column). + * Built-in labels for the platform-injected system fields — the full set + * `injectedSystemColumnDefs` (`../data/injected-system-column-provenance`) + * spreads onto every eligible object with English labels: the tenant scope + * anchor `organization_id`, the audit family `created_*` / `updated_*`, + * `owner_id` and `owning_business_unit_id`. Custom objects carry no + * per-object translation entries for these, so without a fallback every + * localized surface — list headers, export files, import templates, the + * `/meta` read exits — leaks the English default (e.g. an otherwise + * fully-Chinese import template with an `Owner` column, or an + * `Organization` field on every business object of a zh-CN tenant). + * + * The identity-stable definitions themselves are never localised in place: + * the ObjectQL registry and the served-document strip read them by exact + * identity, so display-name resolution is a read-exit concern that lives + * HERE, keyed by field name, and applies only while the served label still + * equals the definition's English default (see `builtinSystemFieldLabel`). + * A row's `en` therefore MUST equal the definition's `label` byte for byte + * — one that drifts silently stops matching and the column leaks English + * again; `i18n-resolver.test.ts` pins the pairing from the provenance + * module's own definitions. * * Wording matches the generated platform bundles (`*.objects.generated.ts`) * so a system field reads the same on custom and platform objects. + * `owning_business_unit_id` has no bundle leaf of its own (injected, hidden, + * declared by no platform object), so its wording composes the bundles' + * `sys_business_unit` label with the ownership qualifier their + * `sys_user.primary_business_unit_id` leaves use. */ const SYSTEM_FIELD_LABELS: Record> = { + organization_id: { en: 'Organization', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organización' }, owner_id: { en: 'Owner', 'zh-CN': '所有者', 'ja-JP': '所有者', 'es-ES': 'Propietario' }, created_at: { en: 'Created At', 'zh-CN': '创建时间', 'ja-JP': '作成日時', 'es-ES': 'Creado el' }, created_by: { en: 'Created By', 'zh-CN': '创建人', 'ja-JP': '作成者', 'es-ES': 'Creado por' }, updated_at: { en: 'Last Modified At', 'zh-CN': '更新时间', 'ja-JP': '更新日時', 'es-ES': 'Actualizado el' }, updated_by: { en: 'Last Modified By', 'zh-CN': '更新人', 'ja-JP': '更新者', 'es-ES': 'Actualizado por' }, + owning_business_unit_id: { + en: 'Owning Business Unit', + 'zh-CN': '所属业务单元', + 'ja-JP': '所属ビジネスユニット', + 'es-ES': 'Unidad de negocio propietaria', + }, }; /** From 570a66334fadcc43b3e1b9c1e62c048b0a020648 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 04:20:20 +0000 Subject: [PATCH 2/2] test(rest): pin that every injected system column reaches /meta/object localised Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --- .../meta-object-injected-column-i18n.test.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 packages/rest/src/meta-object-injected-column-i18n.test.ts diff --git a/packages/rest/src/meta-object-injected-column-i18n.test.ts b/packages/rest/src/meta-object-injected-column-i18n.test.ts new file mode 100644 index 0000000000..659bf62643 --- /dev/null +++ b/packages/rest/src/meta-object-injected-column-i18n.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14972 — every platform-injected system column reaches the `/meta/object` + * reads with a localised display name. + * + * The RULE lives in `@objectstack/spec/system` (`translateObject`'s built-in + * system-field label table, unit-tested per column and per shipped locale in + * `i18n-resolver.test.ts`). What can only be tested here is the SEAM: the + * protocol's read exits inject the columns (`applyInjectedSystemColumns`) + * BEFORE this boundary translates the document, and the boundary translates + * even when the tenant's bundle carries nothing for the object — a custom + * object ships no per-object entries for columns it never declared, so the + * built-in table is the only thing that can answer. The served document + * below therefore spreads the columns from the provenance module's own + * definitions, exactly as the protocol's injection does, and the bundle names + * a different object on purpose. + * + * Every injected column is named in the assertion: the defect was two rows + * missing from a table of seven, and a loop over whatever the table happens to + * carry would have been green with them missing. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { injectedSystemColumnDefs } from '@objectstack/spec/data'; +import { RestServer } from './rest-server.js'; + +// --------------------------------------------------------------------------- +// Fixtures — one custom object, every injected column, a bundle that knows +// another object +// --------------------------------------------------------------------------- + +const INJECTED = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } }); + +/** What the protocol serves: the author's field plus the injected columns. */ +const SERVED = { + name: 'contracts', + label: 'Contract', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + ...INJECTED, + }, +}; + +const BUNDLE: Record = { + 'zh-CN': { objects: { showcase_contact: { label: '联系人' } } }, +}; + +const i18nService = { + getLocales: () => ['en', 'zh-CN'], + getTranslations: (locale: string) => BUNDLE[locale], + getDefaultLocale: () => 'en', +}; + +// --------------------------------------------------------------------------- +// Doubles +// --------------------------------------------------------------------------- + +function mockServer() { + 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), + }; +} + +function mockRes() { + return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() }; +} + +function protocol() { + return { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', + routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: any) => (type === 'object' || type === 'objects' ? [SERVED] : [])), + getMetaItem: vi.fn(async ({ type, name }: any) => ({ + type: type === 'objects' ? 'object' : type, + name, + item: SERVED, + lock: 'none', + editable: true, + })), + getMetaItemCached: undefined as any, + findData: vi.fn().mockResolvedValue([]), + }; +} + +function makeRest() { + const rest = new RestServer( + mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, + // i18nServiceProvider — the 14th constructor argument. + async () => i18nService as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return rest; +} + +function routeFor(rest: RestServer, path: string) { + const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + if (!route) throw new Error(`route not registered: GET ${path}`); + return route; +} + +/** The body of the last `res.json(...)` (indexed: this package's `lib` target predates `.at`). */ +function lastBody(res: ReturnType): any { + const calls = res.json.mock.calls; + return calls.length ? calls[calls.length - 1][0] : undefined; +} + +async function itemFields(locale: string): Promise> { + const res = mockRes(); + await routeFor(makeRest(), '/api/v1/meta/:type/:name').handler( + { + method: 'GET', + params: { type: 'object', name: 'contracts' }, + query: {}, + body: {}, + headers: { 'accept-language': locale }, + }, + res, + ); + return lastBody(res)?.item?.fields; +} + +async function listFields(locale: string): Promise> { + const res = mockRes(); + await routeFor(makeRest(), '/api/v1/meta/:type').handler( + { method: 'GET', params: { type: 'object' }, query: {}, body: {}, headers: { 'accept-language': locale } }, + res, + ); + const body = lastBody(res); + const items = Array.isArray(body) ? body : body?.items ?? []; + return items[0]?.fields; +} + +const ZH_CN = { + organization_id: '组织', + created_at: '创建时间', + created_by: '创建人', + updated_at: '更新时间', + updated_by: '更新人', + owner_id: '所有者', + owning_business_unit_id: '所属业务单元', +}; + +function labelsOf(fields: Record): Record { + return { + organization_id: fields.organization_id?.label, + created_at: fields.created_at?.label, + created_by: fields.created_by?.label, + updated_at: fields.updated_at?.label, + updated_by: fields.updated_by?.label, + owner_id: fields.owner_id?.label, + owning_business_unit_id: fields.owning_business_unit_id?.label, + }; +} + +// --------------------------------------------------------------------------- +// The seam +// --------------------------------------------------------------------------- + +describe('#14972 — injected system columns reach the /meta/object reads localised', () => { + it('the fixture spreads all seven injected columns with their shipped English labels', () => { + expect(Object.keys(INJECTED).sort()).toEqual([ + 'created_at', 'created_by', 'organization_id', 'owner_id', + 'owning_business_unit_id', 'updated_at', 'updated_by', + ]); + expect((SERVED.fields as any).organization_id.label).toBe('Organization'); + }); + + it('by-name read: every injected column answers Chinese on a zh-CN request', async () => { + const fields = await itemFields('zh-CN'); + expect(labelsOf(fields)).toEqual(ZH_CN); + // The author's own field is untouched: the bundle carries nothing for it. + expect(fields.title.label).toBe('Title'); + }); + + it('list read: every injected column answers Chinese on a zh-CN request', async () => { + const fields = await listFields('zh-CN'); + expect(labelsOf(fields)).toEqual(ZH_CN); + expect(fields.title.label).toBe('Title'); + }); + + it('an en request keeps the shipped English defaults on both reads', async () => { + for (const fields of [await itemFields('en'), await listFields('en')]) { + expect(labelsOf(fields)).toEqual({ + organization_id: 'Organization', + created_at: 'Created At', + created_by: 'Created By', + updated_at: 'Last Modified At', + updated_by: 'Last Modified By', + owner_id: 'Owner', + owning_business_unit_id: 'Owning Business Unit', + }); + } + }); +});