Skip to content

Commit d8d2776

Browse files
claude[bot]claude
andauthored
fix(spec): localise the tenant-scope and owning-business-unit injected columns on the /meta read exits (#15786)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * test(rest): pin that every injected system column reaches /meta/object localised Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc20f7b commit d8d2776

4 files changed

Lines changed: 356 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
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.
6+
7+
`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.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #14972 — every platform-injected system column reaches the `/meta/object`
5+
* reads with a localised display name.
6+
*
7+
* The RULE lives in `@objectstack/spec/system` (`translateObject`'s built-in
8+
* system-field label table, unit-tested per column and per shipped locale in
9+
* `i18n-resolver.test.ts`). What can only be tested here is the SEAM: the
10+
* protocol's read exits inject the columns (`applyInjectedSystemColumns`)
11+
* BEFORE this boundary translates the document, and the boundary translates
12+
* even when the tenant's bundle carries nothing for the object — a custom
13+
* object ships no per-object entries for columns it never declared, so the
14+
* built-in table is the only thing that can answer. The served document
15+
* below therefore spreads the columns from the provenance module's own
16+
* definitions, exactly as the protocol's injection does, and the bundle names
17+
* a different object on purpose.
18+
*
19+
* Every injected column is named in the assertion: the defect was two rows
20+
* missing from a table of seven, and a loop over whatever the table happens to
21+
* carry would have been green with them missing.
22+
*/
23+
24+
import { describe, it, expect, vi } from 'vitest';
25+
import { injectedSystemColumnDefs } from '@objectstack/spec/data';
26+
import { RestServer } from './rest-server.js';
27+
28+
// ---------------------------------------------------------------------------
29+
// Fixtures — one custom object, every injected column, a bundle that knows
30+
// another object
31+
// ---------------------------------------------------------------------------
32+
33+
const INJECTED = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } });
34+
35+
/** What the protocol serves: the author's field plus the injected columns. */
36+
const SERVED = {
37+
name: 'contracts',
38+
label: 'Contract',
39+
fields: {
40+
title: { name: 'title', type: 'text', label: 'Title' },
41+
...INJECTED,
42+
},
43+
};
44+
45+
const BUNDLE: Record<string, any> = {
46+
'zh-CN': { objects: { showcase_contact: { label: '联系人' } } },
47+
};
48+
49+
const i18nService = {
50+
getLocales: () => ['en', 'zh-CN'],
51+
getTranslations: (locale: string) => BUNDLE[locale],
52+
getDefaultLocale: () => 'en',
53+
};
54+
55+
// ---------------------------------------------------------------------------
56+
// Doubles
57+
// ---------------------------------------------------------------------------
58+
59+
function mockServer() {
60+
return {
61+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
62+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
63+
};
64+
}
65+
66+
function mockRes() {
67+
return { json: vi.fn(), status: vi.fn().mockReturnThis(), header: vi.fn(), send: vi.fn() };
68+
}
69+
70+
function protocol() {
71+
return {
72+
getDiscovery: vi.fn().mockResolvedValue({
73+
version: 'v0',
74+
routes: { data: '', metadata: '', ui: '', auth: '/auth' },
75+
}),
76+
getMetaTypes: vi.fn().mockResolvedValue([]),
77+
getMetaItems: vi.fn(async ({ type }: any) => (type === 'object' || type === 'objects' ? [SERVED] : [])),
78+
getMetaItem: vi.fn(async ({ type, name }: any) => ({
79+
type: type === 'objects' ? 'object' : type,
80+
name,
81+
item: SERVED,
82+
lock: 'none',
83+
editable: true,
84+
})),
85+
getMetaItemCached: undefined as any,
86+
findData: vi.fn().mockResolvedValue([]),
87+
};
88+
}
89+
90+
function makeRest() {
91+
const rest = new RestServer(
92+
mockServer() as any, protocol() as any, { api: { requireAuth: false } } as any,
93+
undefined, undefined, undefined, undefined, undefined,
94+
undefined, undefined, undefined, undefined, undefined,
95+
// i18nServiceProvider — the 14th constructor argument.
96+
async () => i18nService as any,
97+
);
98+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] });
99+
rest.registerRoutes();
100+
return rest;
101+
}
102+
103+
function routeFor(rest: RestServer, path: string) {
104+
const route = (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path);
105+
if (!route) throw new Error(`route not registered: GET ${path}`);
106+
return route;
107+
}
108+
109+
/** The body of the last `res.json(...)` (indexed: this package's `lib` target predates `.at`). */
110+
function lastBody(res: ReturnType<typeof mockRes>): any {
111+
const calls = res.json.mock.calls;
112+
return calls.length ? calls[calls.length - 1][0] : undefined;
113+
}
114+
115+
async function itemFields(locale: string): Promise<Record<string, any>> {
116+
const res = mockRes();
117+
await routeFor(makeRest(), '/api/v1/meta/:type/:name').handler(
118+
{
119+
method: 'GET',
120+
params: { type: 'object', name: 'contracts' },
121+
query: {},
122+
body: {},
123+
headers: { 'accept-language': locale },
124+
},
125+
res,
126+
);
127+
return lastBody(res)?.item?.fields;
128+
}
129+
130+
async function listFields(locale: string): Promise<Record<string, any>> {
131+
const res = mockRes();
132+
await routeFor(makeRest(), '/api/v1/meta/:type').handler(
133+
{ method: 'GET', params: { type: 'object' }, query: {}, body: {}, headers: { 'accept-language': locale } },
134+
res,
135+
);
136+
const body = lastBody(res);
137+
const items = Array.isArray(body) ? body : body?.items ?? [];
138+
return items[0]?.fields;
139+
}
140+
141+
const ZH_CN = {
142+
organization_id: '组织',
143+
created_at: '创建时间',
144+
created_by: '创建人',
145+
updated_at: '更新时间',
146+
updated_by: '更新人',
147+
owner_id: '所有者',
148+
owning_business_unit_id: '所属业务单元',
149+
};
150+
151+
function labelsOf(fields: Record<string, any>): Record<string, unknown> {
152+
return {
153+
organization_id: fields.organization_id?.label,
154+
created_at: fields.created_at?.label,
155+
created_by: fields.created_by?.label,
156+
updated_at: fields.updated_at?.label,
157+
updated_by: fields.updated_by?.label,
158+
owner_id: fields.owner_id?.label,
159+
owning_business_unit_id: fields.owning_business_unit_id?.label,
160+
};
161+
}
162+
163+
// ---------------------------------------------------------------------------
164+
// The seam
165+
// ---------------------------------------------------------------------------
166+
167+
describe('#14972 — injected system columns reach the /meta/object reads localised', () => {
168+
it('the fixture spreads all seven injected columns with their shipped English labels', () => {
169+
expect(Object.keys(INJECTED).sort()).toEqual([
170+
'created_at', 'created_by', 'organization_id', 'owner_id',
171+
'owning_business_unit_id', 'updated_at', 'updated_by',
172+
]);
173+
expect((SERVED.fields as any).organization_id.label).toBe('Organization');
174+
});
175+
176+
it('by-name read: every injected column answers Chinese on a zh-CN request', async () => {
177+
const fields = await itemFields('zh-CN');
178+
expect(labelsOf(fields)).toEqual(ZH_CN);
179+
// The author's own field is untouched: the bundle carries nothing for it.
180+
expect(fields.title.label).toBe('Title');
181+
});
182+
183+
it('list read: every injected column answers Chinese on a zh-CN request', async () => {
184+
const fields = await listFields('zh-CN');
185+
expect(labelsOf(fields)).toEqual(ZH_CN);
186+
expect(fields.title.label).toBe('Title');
187+
});
188+
189+
it('an en request keeps the shipped English defaults on both reads', async () => {
190+
for (const fields of [await itemFields('en'), await listFields('en')]) {
191+
expect(labelsOf(fields)).toEqual({
192+
organization_id: 'Organization',
193+
created_at: 'Created At',
194+
created_by: 'Created By',
195+
updated_at: 'Last Modified At',
196+
updated_by: 'Last Modified By',
197+
owner_id: 'Owner',
198+
owning_business_unit_id: 'Owning Business Unit',
199+
});
200+
}
201+
});
202+
});

packages/spec/src/system/i18n-resolver.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2212,6 +2212,122 @@ describe('translateObject system-field label fallback', () => {
22122212
});
22132213
});
22142214

2215+
// ────────────────────────────────────────────────────────────────────────────
2216+
// translateObject — EVERY platform-injected column, per shipped locale
2217+
// (objectstack#14972)
2218+
// ────────────────────────────────────────────────────────────────────────────
2219+
2220+
import { injectedSystemColumnDefs } from '../data/injected-system-column-provenance';
2221+
2222+
describe('translateObject localises every platform-injected column (objectstack#14972)', () => {
2223+
// The column definitions come from the provenance module itself — the same
2224+
// objects `applySystemFields` spreads at registration and the `/meta` read
2225+
// exits serve — so the English defaults this block starts from cannot drift
2226+
// from the shipped tables through a retyped label. The document is a custom
2227+
// object that ships no translation entries of its own, and the bundle is
2228+
// absent: only the built-in table can answer.
2229+
const injected = injectedSystemColumnDefs({ name: 'contracts', fields: { title: { type: 'text' } } });
2230+
const doc = {
2231+
name: 'contracts',
2232+
label: 'Contract',
2233+
fields: {
2234+
title: { name: 'title', type: 'text', label: '合同名称' },
2235+
...(injected as Record<string, any>),
2236+
},
2237+
};
2238+
const SHIPPED_LOCALES = ['en', 'zh-CN', 'ja-JP', 'es-ES'] as const;
2239+
const labelsFor = (locale: string): Record<string, string> => {
2240+
const out = translateObject(doc, undefined, { locale, fallbackChain: [locale] });
2241+
const fields = out.fields as Record<string, any>;
2242+
return Object.fromEntries(Object.keys(injected).map((name) => [name, fields[name].label]));
2243+
};
2244+
2245+
it('starts from all seven injected columns carrying their shipped English defaults', () => {
2246+
expect(Object.keys(injected).sort()).toEqual([
2247+
'created_at',
2248+
'created_by',
2249+
'organization_id',
2250+
'owner_id',
2251+
'owning_business_unit_id',
2252+
'updated_at',
2253+
'updated_by',
2254+
]);
2255+
// An `en` request leaves every label exactly as the definition ships it.
2256+
expect(labelsFor('en')).toEqual({
2257+
organization_id: 'Organization',
2258+
created_at: 'Created At',
2259+
created_by: 'Created By',
2260+
updated_at: 'Last Modified At',
2261+
updated_by: 'Last Modified By',
2262+
owner_id: 'Owner',
2263+
owning_business_unit_id: 'Owning Business Unit',
2264+
});
2265+
});
2266+
2267+
it('zh-CN: every injected column reads Chinese, in the platform bundles\' wording', () => {
2268+
expect(labelsFor('zh-CN')).toEqual({
2269+
organization_id: '组织',
2270+
created_at: '创建时间',
2271+
created_by: '创建人',
2272+
updated_at: '更新时间',
2273+
updated_by: '更新人',
2274+
owner_id: '所有者',
2275+
owning_business_unit_id: '所属业务单元',
2276+
});
2277+
});
2278+
2279+
it('ja-JP: every injected column reads Japanese, in the platform bundles\' wording', () => {
2280+
expect(labelsFor('ja-JP')).toEqual({
2281+
organization_id: '組織',
2282+
created_at: '作成日時',
2283+
created_by: '作成者',
2284+
updated_at: '更新日時',
2285+
updated_by: '更新者',
2286+
owner_id: '所有者',
2287+
owning_business_unit_id: '所属ビジネスユニット',
2288+
});
2289+
});
2290+
2291+
it('es-ES: every injected column reads Spanish, in the platform bundles\' wording', () => {
2292+
expect(labelsFor('es-ES')).toEqual({
2293+
organization_id: 'Organización',
2294+
created_at: 'Creado el',
2295+
created_by: 'Creado por',
2296+
updated_at: 'Actualizado el',
2297+
updated_by: 'Actualizado por',
2298+
owner_id: 'Propietario',
2299+
owning_business_unit_id: 'Unidad de negocio propietaria',
2300+
});
2301+
});
2302+
2303+
it('a tenant that relabelled the organization column keeps its label on every locale', () => {
2304+
// The guard is comparison-based: the built-in row applies only while the
2305+
// served label still equals the definition's English default. A label the
2306+
// tenant (or the author) wrote is authored data and wins on every locale,
2307+
// the `en` request included.
2308+
const renamed = {
2309+
...doc,
2310+
fields: {
2311+
...doc.fields,
2312+
organization_id: { ...(injected.organization_id as Record<string, any>), label: '所属公司' },
2313+
},
2314+
};
2315+
for (const locale of SHIPPED_LOCALES) {
2316+
const out = translateObject(renamed, undefined, { locale, fallbackChain: [locale] });
2317+
expect((out.fields as any).organization_id.label, locale).toBe('所属公司');
2318+
// The untouched columns still localise around it.
2319+
expect((out.fields as any).owner_id.label, locale).toBe(labelsFor(locale).owner_id);
2320+
}
2321+
});
2322+
2323+
it('never mutates the input document or the shipped definitions', () => {
2324+
labelsFor('zh-CN');
2325+
expect((doc.fields as any).organization_id.label).toBe('Organization');
2326+
expect((doc.fields as any).owning_business_unit_id.label).toBe('Owning Business Unit');
2327+
expect(injected.organization_id.label).toBe('Organization');
2328+
});
2329+
});
2330+
22152331
describe('translateObject inline actions (objectstack#3370)', () => {
22162332
// The `sys_approval_request` shape: decision actions declared inline on the
22172333
// object. The plugin ships `_actions` translations for them, but the object

packages/spec/src/system/i18n-resolver.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2094,22 +2094,47 @@ function lookupObjectFieldOption(
20942094
}
20952095

20962096
/**
2097-
* Built-in labels for the platform-injected system fields (the ObjectQL
2098-
* registry stamps `owner_id` / `created_*` / `updated_*` onto every object
2099-
* with English labels). Custom objects carry no per-object translation
2100-
* entries for these, so without a fallback every localized surface — list
2101-
* headers, export files, import templates — leaks the English default (e.g.
2102-
* an otherwise fully-Chinese import template with an `Owner` column).
2097+
* Built-in labels for the platform-injected system fields — the full set
2098+
* `injectedSystemColumnDefs` (`../data/injected-system-column-provenance`)
2099+
* spreads onto every eligible object with English labels: the tenant scope
2100+
* anchor `organization_id`, the audit family `created_*` / `updated_*`,
2101+
* `owner_id` and `owning_business_unit_id`. Custom objects carry no
2102+
* per-object translation entries for these, so without a fallback every
2103+
* localized surface — list headers, export files, import templates, the
2104+
* `/meta` read exits — leaks the English default (e.g. an otherwise
2105+
* fully-Chinese import template with an `Owner` column, or an
2106+
* `Organization` field on every business object of a zh-CN tenant).
2107+
*
2108+
* The identity-stable definitions themselves are never localised in place:
2109+
* the ObjectQL registry and the served-document strip read them by exact
2110+
* identity, so display-name resolution is a read-exit concern that lives
2111+
* HERE, keyed by field name, and applies only while the served label still
2112+
* equals the definition's English default (see `builtinSystemFieldLabel`).
2113+
* A row's `en` therefore MUST equal the definition's `label` byte for byte
2114+
* — one that drifts silently stops matching and the column leaks English
2115+
* again; `i18n-resolver.test.ts` pins the pairing from the provenance
2116+
* module's own definitions.
21032117
*
21042118
* Wording matches the generated platform bundles (`*.objects.generated.ts`)
21052119
* so a system field reads the same on custom and platform objects.
2120+
* `owning_business_unit_id` has no bundle leaf of its own (injected, hidden,
2121+
* declared by no platform object), so its wording composes the bundles'
2122+
* `sys_business_unit` label with the ownership qualifier their
2123+
* `sys_user.primary_business_unit_id` leaves use.
21062124
*/
21072125
const SYSTEM_FIELD_LABELS: Record<string, Record<string, string>> = {
2126+
organization_id: { en: 'Organization', 'zh-CN': '组织', 'ja-JP': '組織', 'es-ES': 'Organización' },
21082127
owner_id: { en: 'Owner', 'zh-CN': '所有者', 'ja-JP': '所有者', 'es-ES': 'Propietario' },
21092128
created_at: { en: 'Created At', 'zh-CN': '创建时间', 'ja-JP': '作成日時', 'es-ES': 'Creado el' },
21102129
created_by: { en: 'Created By', 'zh-CN': '创建人', 'ja-JP': '作成者', 'es-ES': 'Creado por' },
21112130
updated_at: { en: 'Last Modified At', 'zh-CN': '更新时间', 'ja-JP': '更新日時', 'es-ES': 'Actualizado el' },
21122131
updated_by: { en: 'Last Modified By', 'zh-CN': '更新人', 'ja-JP': '更新者', 'es-ES': 'Actualizado por' },
2132+
owning_business_unit_id: {
2133+
en: 'Owning Business Unit',
2134+
'zh-CN': '所属业务单元',
2135+
'ja-JP': '所属ビジネスユニット',
2136+
'es-ES': 'Unidad de negocio propietaria',
2137+
},
21132138
};
21142139

21152140
/**

0 commit comments

Comments
 (0)