Skip to content

Commit f5a325f

Browse files
committed
fix(service-settings)!: retire date_format, time_format, number_format and first_day_of_week from the Localization manifest (#21958)
Formats follow the locale (ruled on objectui#11675, B). The four rows, the now-empty Formats group and their entries in the en, es-ES, ja-JP and zh-CN bundles leave; the manifest version goes to 2 per the spec's SettingsManifest.version contract (increment when keys are removed). A stored sys_setting row for one of the four is kept: it is not resolved on read, a write naming it is refused 400 UNKNOWN_KEY, and no reset or save of the live keys rewrites or deletes it. Pinned at the REST surface. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80f9f7e commit f5a325f

9 files changed

Lines changed: 186 additions & 106 deletions

File tree

‎packages/services/service-settings/src/manifests/localization.manifest.test.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ describe('localizationSettingsManifest', () => {
99
expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow();
1010
});
1111

12-
it('declares namespace=localization, scope=tenant, version=1', () => {
12+
it('declares namespace=localization, scope=tenant, version=2', () => {
1313
const parsed = SettingsManifestSchema.parse(localizationSettingsManifest);
1414
expect(parsed.namespace).toBe('localization');
1515
expect(parsed.scope).toBe('tenant'); // 组织级 (per-user overrides out of scope for v1)
16-
expect(parsed.version).toBe(1);
16+
// 2 since four keys left (#21958): the spec's `version` contract is
17+
// "increment when keys are renamed/removed".
18+
expect(parsed.version).toBe(2);
1719
});
1820

1921
it('defaults to UTC / en-US — preserving pre-Phase-2 behavior when nothing is set', () => {
@@ -25,8 +27,6 @@ describe('localizationSettingsManifest', () => {
2527
// plain number unless the workspace explicitly sets one (avoids surfacing
2628
// an unwanted "$"/"US$" on every amount that omits its own code).
2729
expect(byKey('currency').default).toBeUndefined();
28-
expect(byKey('date_format').default).toBe('YYYY-MM-DD');
29-
expect(byKey('first_day_of_week').default).toBe('monday');
3030
expect(byKey('fiscal_year_start').default).toBe('january');
3131
});
3232

@@ -43,8 +43,7 @@ describe('localizationSettingsManifest', () => {
4343
expect(byKey('default_country').valueDomain).toBe('iso_3166_alpha2');
4444
// The registry-backed selects stay UNDECLARED on purpose: their tables ARE
4545
// the supported sets (#5131 exhaustive semantics), not standards.
46-
for (const key of ['locale', 'date_format', 'time_format', 'number_format',
47-
'first_day_of_week', 'fiscal_year_start']) {
46+
for (const key of ['locale', 'fiscal_year_start']) {
4847
expect(byKey(key).valueDomain, `${key} must not declare a domain`).toBeUndefined();
4948
}
5049
// And the declaration round-trips the spec parse (the enum is closed —
@@ -63,15 +62,28 @@ describe('localizationSettingsManifest', () => {
6362
}
6463
});
6564

66-
it('exposes the nine regional keys grouped into region/formats/finance', () => {
65+
it('exposes the five regional keys grouped into region/finance', () => {
6766
const specs = localizationSettingsManifest.specifiers as any[];
6867
const keys = specs.filter((s) => s.key).map((s) => s.key);
6968
expect(keys).toEqual([
7069
'timezone', 'locale', 'default_country',
71-
'date_format', 'time_format', 'number_format', 'first_day_of_week',
7270
'currency', 'fiscal_year_start',
7371
]);
7472
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
75-
expect(groups).toEqual(['region', 'formats', 'finance']);
73+
expect(groups).toEqual(['region', 'finance']);
74+
});
75+
76+
it('offers none of the four retired format keys, nor their empty group (#21958)', () => {
77+
// Ruled on objectui#11675 (B): dates, times, numbers and the first day of
78+
// the week follow `locale`; the four separate settings are retired, not
79+
// implemented. Nothing ever read them, so offering them was a form that
80+
// saved values with no effect. The Setup page renders exactly these
81+
// specifiers (`GET /api/settings/localization` serves this manifest).
82+
const specs = localizationSettingsManifest.specifiers as any[];
83+
const offered = specs.filter((s) => s.key).map((s) => s.key);
84+
for (const key of ['date_format', 'time_format', 'number_format', 'first_day_of_week']) {
85+
expect(offered, `${key} must not be offered`).not.toContain(key);
86+
}
87+
expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).not.toContain('formats');
7688
});
7789
});

‎packages/services/service-settings/src/manifests/localization.manifest.ts‎

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@ import type { SettingsManifest } from '@objectstack/spec/system';
66
* Localization — workspace-wide regional defaults (ADR-0053 Phase 2 follow-up).
77
*
88
* The single source of truth for the platform's reference timezone, language,
9-
* currency, and display formats. `resolveExecutionContext` reads `timezone`
10-
* and `locale` from here (cascade: platform default → global → tenant) onto
11-
* every `ExecutionContext`, so formulas (`today()`), analytics date bucketing,
12-
* and rendered `datetime` instants all resolve against the org's region.
9+
* country and currency. `resolveLocalizationContext` (`@objectstack/core`)
10+
* reads `timezone`, `locale` and `currency` from here (cascade: platform
11+
* default → global → tenant) onto every `ExecutionContext`, so formulas
12+
* (`today()`), analytics date bucketing, and rendered `datetime` instants all
13+
* resolve against the org's region.
14+
*
15+
* Display formats follow the locale. How dates, times and numbers are written,
16+
* and which day a week starts on, come from `locale` (language and region), the
17+
* way Salesforce derives them from a user's Locale. Four separate settings for
18+
* them (`date_format`, `time_format`, `number_format`, `first_day_of_week`)
19+
* were offered here until #21958 and nothing ever read them; they were retired
20+
* rather than implemented (the ruling recorded on objectui#11675). Removing
21+
* them is why `version` is 2: the spec's `SettingsManifest.version` contract is
22+
* "increment when keys are renamed/removed". A `sys_setting` row stored for one
23+
* of them is kept, never deleted. The service no longer resolves it (it does
24+
* not appear in `GET /api/settings/localization`), and a write naming the key
25+
* is refused `400 UNKNOWN_KEY`, the answer every undeclared key gets.
1326
*
1427
* Scope is `tenant`: one org per physical tenant (ADR-0002) sets its regional
1528
* defaults; the manifest `default` of each key is the platform built-in, and a
@@ -18,10 +31,10 @@ import type { SettingsManifest } from '@objectstack/spec/system';
1831
*/
1932
export const localizationSettingsManifest: SettingsManifest = {
2033
namespace: 'localization',
21-
version: 1,
34+
version: 2,
2235
label: 'Localization',
2336
icon: 'Globe',
24-
description: 'Default timezone, language, currency, and date/number formats.',
37+
description: 'Default timezone, language, country, currency, and fiscal year.',
2538
scope: 'tenant',
2639
readPermission: 'setup.access',
2740
writePermission: 'setup.write',
@@ -78,45 +91,6 @@ export const localizationSettingsManifest: SettingsManifest = {
7891
valueDomain: 'iso_3166_alpha2',
7992
},
8093

81-
// ── Formats ───────────────────────────────────────────────────────────
82-
{ type: 'group', id: 'formats', label: 'Formats', required: false },
83-
{
84-
type: 'select', key: 'date_format', label: 'Date format', required: false, default: 'YYYY-MM-DD',
85-
options: [
86-
{ value: 'YYYY-MM-DD', label: '2026-06-17 (ISO)' },
87-
{ value: 'MM/DD/YYYY', label: '06/17/2026 (US)' },
88-
{ value: 'DD/MM/YYYY', label: '17/06/2026 (EU)' },
89-
{ value: 'DD.MM.YYYY', label: '17.06.2026' },
90-
{ value: 'DD-MMM-YYYY', label: '17-Jun-2026' },
91-
],
92-
},
93-
{
94-
type: 'select', key: 'time_format', label: 'Time format', required: false, default: '24h',
95-
options: [
96-
{ value: '24h', label: '24-hour (14:30)' },
97-
{ value: '12h', label: '12-hour (2:30 PM)' },
98-
],
99-
},
100-
{
101-
type: 'select', key: 'number_format', label: 'Number format', required: false, default: '1,234.56',
102-
description: 'Grouping and decimal separators for displayed numbers.',
103-
options: [
104-
{ value: '1,234.56', label: '1,234.56 (comma / dot)' },
105-
{ value: '1.234,56', label: '1.234,56 (dot / comma)' },
106-
{ value: '1 234,56', label: '1 234,56 (space / comma)' },
107-
{ value: '1,23,456.78', label: '1,23,456.78 (Indian)' },
108-
],
109-
},
110-
{
111-
type: 'select', key: 'first_day_of_week', label: 'First day of week', required: false, default: 'monday',
112-
description: 'Anchors weekly analytics buckets and calendar grids.',
113-
options: [
114-
{ value: 'monday', label: 'Monday (ISO)' },
115-
{ value: 'sunday', label: 'Sunday' },
116-
{ value: 'saturday', label: 'Saturday' },
117-
],
118-
},
119-
12094
// ── Finance ───────────────────────────────────────────────────────────
12195
{ type: 'group', id: 'finance', label: 'Finance', required: false },
12296
{

‎packages/services/service-settings/src/settings-routes.test.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,3 +666,98 @@ describe('settings-routes — #7522 encrypted values are redacted at the REST bo
666666
);
667667
});
668668
});
669+
670+
// ─────────────────────────────────────────────────────────────────────────────
671+
// #21958 — `date_format`, `time_format`, `number_format` and `first_day_of_week`
672+
// left the Localization manifest (ruled on objectui#11675: formats follow the
673+
// locale). These pin what the REST surface does with a value a workspace
674+
// stored for one of them before the upgrade:
675+
//
676+
// - read: not offered and not resolved — absent from the served manifest
677+
// (which is what the Setup page renders) and from `values`;
678+
// - write: refused `400 UNKNOWN_KEY`, naming the key, like any undeclared key;
679+
// - the stored row is KEPT: no read, write or reset path rewrites or deletes it,
680+
// and it never blocks a save of the keys that remain.
681+
//
682+
// The rows are seeded store-level, the way `seedGlobalSecret` above seeds one:
683+
// after the upgrade no public write path can produce them.
684+
// ─────────────────────────────────────────────────────────────────────────────
685+
686+
const RETIRED_FORMAT_ROWS: Record<string, string> = {
687+
date_format: 'DD.MM.YYYY',
688+
time_format: '12h',
689+
number_format: '1.234,56',
690+
first_day_of_week: 'sunday',
691+
};
692+
693+
async function makeRetiredFormatStack() {
694+
const svc = new SettingsService({ env: {} });
695+
svc.registerManifest(localizationSettingsManifest);
696+
for (const [key, value] of Object.entries(RETIRED_FORMAT_ROWS)) {
697+
await (svc as any).upsertRow({
698+
namespace: 'localization', key, scope: 'tenant', user_id: null,
699+
value, value_enc: null, encrypted: false,
700+
});
701+
}
702+
const http = new MockHttp();
703+
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });
704+
/** The store's own rows for the four keys, read the way every resolve reads them. */
705+
const storedFormatRows = async () => {
706+
const rows = (await (svc as any).loadRows('localization', null)) as Array<{ key: string; value: unknown }>;
707+
return Object.fromEntries(rows.filter((r) => r.key in RETIRED_FORMAT_ROWS).map((r) => [r.key, r.value]));
708+
};
709+
return { svc, http, storedFormatRows };
710+
}
711+
712+
describe('settings-routes — the four retired Localization format keys (#21958)', () => {
713+
it('GET /api/settings/localization offers none of them, even with a stored value', async () => {
714+
const { http, storedFormatRows } = await makeRetiredFormatStack();
715+
const { req, res, state } = makeReqRes({ params: { namespace: 'localization' } });
716+
await http.routes.get('GET /api/settings/:namespace')!(req, res);
717+
718+
expect(state.status).toBe(200);
719+
const specifiers = state.body.data.manifest.specifiers as Array<{ key?: string; id?: string }>;
720+
const offered = specifiers.map((s) => s.key).filter(Boolean);
721+
for (const key of Object.keys(RETIRED_FORMAT_ROWS)) {
722+
expect(offered, `${key} must not be offered`).not.toContain(key);
723+
expect(Object.keys(state.body.data.values), `${key} must not be resolved`).not.toContain(key);
724+
}
725+
expect(specifiers.map((s) => s.id)).not.toContain('formats');
726+
// The five live keys still answer.
727+
expect(Object.keys(state.body.data.values).sort()).toEqual(
728+
['currency', 'default_country', 'fiscal_year_start', 'locale', 'timezone'],
729+
);
730+
expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
731+
});
732+
733+
it('PUT naming one of them is refused 400 UNKNOWN_KEY, and the stored row is untouched', async () => {
734+
const { http, storedFormatRows } = await makeRetiredFormatStack();
735+
for (const key of Object.keys(RETIRED_FORMAT_ROWS)) {
736+
const { req, res, state } = makeReqRes({
737+
params: { namespace: 'localization' },
738+
// A live key in the same batch: the refusal is whole-batch, nothing lands.
739+
body: { [key]: 'anything', timezone: 'Europe/Paris' },
740+
});
741+
await http.routes.get('PUT /api/settings/:namespace')!(req, res);
742+
expect(state.status).toBe(400);
743+
expect(state.body.error.code).toBe('UNKNOWN_KEY');
744+
expect(state.body.error.details).toEqual({ namespace: 'localization', key });
745+
}
746+
expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
747+
});
748+
749+
it('a stored retired row never blocks saving the live keys, and reset leaves it in place', async () => {
750+
const { svc, http, storedFormatRows } = await makeRetiredFormatStack();
751+
const put = makeReqRes({ params: { namespace: 'localization' }, body: { timezone: 'Europe/Paris' } });
752+
await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res);
753+
expect(put.state.status).toBe(200);
754+
expect(put.state.body.data.values.timezone.value).toBe('Europe/Paris');
755+
756+
const reset = makeReqRes({ params: { namespace: 'localization', actionId: 'reset' }, body: {} });
757+
await http.routes.get('POST /api/settings/:namespace/:actionId')!(reset.req, reset.res);
758+
expect(reset.state.status).toBe(200);
759+
expect((await svc.get('localization', 'timezone')).source).toBe('default');
760+
761+
expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
762+
});
763+
});

‎packages/services/service-settings/src/settings-service.test.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2200,7 +2200,8 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
22002200
// The registry-backed shape (`mail.provider`, `sms.provider`): its table
22012201
// IS the supported set, and declaring no domain must keep it that way,
22022202
// byte-for-byte. Pinned on the mail manifest itself plus a localization
2203-
// key that deliberately declares no domain.
2203+
// key that deliberately declares no domain (`fiscal_year_start`, a closed
2204+
// table of twelve months).
22042205
const svc = new SettingsService({ env: {} });
22052206
svc.registerManifest(mailSettingsManifest);
22062207
await expect(svc.setMany('mail', { provider: 'sendgrid' })).rejects.toMatchObject({
@@ -2209,9 +2210,9 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
22092210
});
22102211

22112212
const loc = localizationService();
2212-
await expect(loc.setMany('localization', { first_day_of_week: 'thursday' })).rejects.toMatchObject({
2213+
await expect(loc.setMany('localization', { fiscal_year_start: 'smarch' })).rejects.toMatchObject({
22132214
code: 'SETTINGS_VALIDATION',
2214-
fields: [{ field: 'first_day_of_week', code: 'invalid_option' }],
2215+
fields: [{ field: 'fiscal_year_start', code: 'invalid_option' }],
22152216
});
22162217
});
22172218

@@ -2387,11 +2388,11 @@ describe('SettingsService — env overrides are judged against the declared valu
23872388

23882389
it('env door for a domain-less select keeps exhaustive options — the #5131/#5204 regression pin', async () => {
23892390
const { errors, logger } = spyLogger();
2390-
const svc = new SettingsService({ env: { OS_LOCALIZATION_DATE_FORMAT: 'DD>MM>YYYY' }, logger });
2391+
const svc = new SettingsService({ env: { OS_LOCALIZATION_FISCAL_YEAR_START: 'smarch' }, logger });
23912392
svc.registerManifest(localizationSettingsManifest);
23922393

2393-
const r = await svc.get('localization', 'date_format');
2394-
expect(r.value).toBe('YYYY-MM-DD'); // the default — the override is not in force
2394+
const r = await svc.get('localization', 'fiscal_year_start');
2395+
expect(r.value).toBe('january'); // the default — the override is not in force
23952396
expect(r.source).toBe('default');
23962397
expect(errors).toHaveLength(1);
23972398
expect(errors[0]).toContain('is not a declared option for');

‎packages/services/service-settings/src/translations/en.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,15 @@ export const en: PlatformTranslationData = {
108108

109109
localization: {
110110
title: 'Localization',
111-
description: 'Default timezone, language, currency, and date/number formats.',
111+
description: 'Default timezone, language, country, currency, and fiscal year.',
112112
groups: {
113113
region: { title: 'Region' },
114-
formats: { title: 'Formats' },
115114
finance: { title: 'Finance' },
116115
},
117116
keys: {
118117
timezone: { label: 'Default timezone', help: 'IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes.' },
119118
locale: { label: 'Default language', help: 'BCP-47 locale for message catalogs and number/date formatting.' },
120119
default_country: { label: 'Default country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' },
121-
date_format: { label: 'Date format' },
122-
time_format: { label: 'Time format', options: { '24h': '24-hour (14:30)', '12h': '12-hour (2:30 PM)' } },
123-
number_format: { label: 'Number format' },
124-
first_day_of_week: { label: 'First day of week', options: { monday: 'Monday (ISO)', sunday: 'Sunday', saturday: 'Saturday' } },
125120
currency: { label: 'Default currency' },
126121
fiscal_year_start: { label: 'Fiscal year start' },
127122
},

‎packages/services/service-settings/src/translations/es-ES.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,20 +290,15 @@ export const esES: PlatformTranslationData = {
290290

291291
localization: {
292292
title: 'Localización',
293-
description: 'Zona horaria, idioma, moneda y formatos de fecha/número predeterminados.',
293+
description: 'Zona horaria, idioma, país, moneda y año fiscal predeterminados.',
294294
groups: {
295295
region: { title: 'Región' },
296-
formats: { title: 'Formatos' },
297296
finance: { title: 'Finanzas' },
298297
},
299298
keys: {
300299
timezone: { label: 'Zona horaria predeterminada', help: 'Zona IANA usada para resolver today()/daysFromNow, los buckets de fecha de analítica y las fechas/horas renderizadas.' },
301300
locale: { label: 'Idioma predeterminado', help: 'Configuración regional BCP-47 para los catálogos de mensajes y el formato de números/fechas.' },
302301
default_country: { label: 'País predeterminado', help: 'Código ISO 3166-1 alfa-2 (p. ej. US, GB, CN). Se usa para los valores por defecto de dirección y teléfono.' },
303-
date_format: { label: 'Formato de fecha' },
304-
time_format: { label: 'Formato de hora' },
305-
number_format: { label: 'Formato de número', help: 'Separadores de miles y decimales para los números mostrados.' },
306-
first_day_of_week: { label: 'Primer día de la semana', help: 'Ancla los buckets de analítica semanal y las cuadrículas de calendario.' },
307302
currency: { label: 'Moneda predeterminada', help: 'Código ISO 4217 aplicado cuando un campo de moneda no indica la suya.' },
308303
fiscal_year_start: { label: 'Inicio del año fiscal', help: 'Primer mes del año fiscal: determina «este trimestre / año fiscal» en los informes.' },
309304
},

0 commit comments

Comments
 (0)