Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/21958-retire-locale-format-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/service-settings': minor
---

fix(service-settings)!: the Localization settings no longer offer `date_format`, `time_format`, `number_format` or `first_day_of_week`: dates, times, numbers and the week start follow the locale (#21958)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) The four keys were settings values, not metadata: only the platform-shipped localizationSettingsManifest declared them (only platform code declares a settings manifest), SettingsManifestSchema and every other spec declaration are unchanged, and a value stored for one of them lives in sys_setting (runtime state, ADR-0007), not sys_metadata, and is kept as it is. Nothing read the keys in objectstack, objectui, cloud or hotcrm, so no consumer has anything to rewrite and objectstack migrate meta has nothing to reach. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a settings key (not already-registered); and the change is the value of an exported manifest constant, not an interface or a type alone (not runtime-interface-only or type-surface-only). -->

**BREAKING**: the Localization settings namespace (`localizationSettingsManifest`) drops four specifiers that nothing ever read: `date_format`, `time_format`, `number_format` and `first_day_of_week`, together with the Formats group they made up and their copy in the `en`, `zh-CN`, `ja-JP` and `es-ES` settings bundles. Setup → Localization no longer shows them, and `GET /api/settings/localization` no longer serves them. The maintainer ruled that dates, times, numbers and the first day of the week follow the user's locale (language and region), as Salesforce derives them from a Locale, and that the four separate settings retire rather than being implemented. `timezone`, `locale`, `default_country`, `currency` and `fiscal_year_start` are unchanged. The manifest's `version` is now 2, as the `SettingsManifest.version` contract asks when keys are removed.

**A value a workspace already stored for one of the four is kept.** Measured at the REST surface:

- The `sys_setting` row stays exactly as it was. No read, save or reset rewrites or deletes it.
- `GET /api/settings/localization` does not resolve it: the key is absent from both `manifest.specifiers` and `values`.
- A `PUT /api/settings/localization` that names one of the four is refused with `400 UNKNOWN_KEY` (`details.key` names it), the answer every undeclared key gets. The refusal covers the whole batch, so a live key sent beside it does not land either.
- A stored row never blocks saving the live keys, and the built-in `reset` action leaves it in place.
- In process, `settings.get('localization', 'date_format')` (or any of the four) now rejects with `SETTINGS_UNKNOWN_KEY`.
- An `OS_LOCALIZATION_DATE_FORMAT`, `OS_LOCALIZATION_TIME_FORMAT`, `OS_LOCALIZATION_NUMBER_FORMAT` or `OS_LOCALIZATION_FIRST_DAY_OF_WEEK` variable is no longer read. It set a value that nothing read before either.

**What to do after upgrading.** Nothing has to be rewritten: the four keys never changed how anything rendered. There is no replacement key. `locale`, the workspace's language and region, is what decides how dates, times and numbers are written. The console's calendar and timeline do not yet take their first day of the week from it; that is objectui work, tracked and shipped separately.

It ships as `minor` under the launch-window convention for narrowings.
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ describe('localizationSettingsManifest', () => {
expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow();
});

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

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

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

it('exposes the nine regional keys grouped into region/formats/finance', () => {
it('exposes the five regional keys grouped into region/finance', () => {
const specs = localizationSettingsManifest.specifiers as any[];
const keys = specs.filter((s) => s.key).map((s) => s.key);
expect(keys).toEqual([
'timezone', 'locale', 'default_country',
'date_format', 'time_format', 'number_format', 'first_day_of_week',
'currency', 'fiscal_year_start',
]);
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
expect(groups).toEqual(['region', 'formats', 'finance']);
expect(groups).toEqual(['region', 'finance']);
});

it('offers none of the four retired format keys, nor their empty group (#21958)', () => {
// Ruled on objectui#11675 (B): dates, times, numbers and the first day of
// the week follow `locale`; the four separate settings are retired, not
// implemented. Nothing ever read them, so offering them was a form that
// saved values with no effect. The Setup page renders exactly these
// specifiers (`GET /api/settings/localization` serves this manifest).
const specs = localizationSettingsManifest.specifiers as any[];
const offered = specs.filter((s) => s.key).map((s) => s.key);
for (const key of ['date_format', 'time_format', 'number_format', 'first_day_of_week']) {
expect(offered, `${key} must not be offered`).not.toContain(key);
}
expect(specs.filter((s) => s.type === 'group').map((s) => s.id)).not.toContain('formats');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,23 @@ import type { SettingsManifest } from '@objectstack/spec/system';
* Localization — workspace-wide regional defaults (ADR-0053 Phase 2 follow-up).
*
* The single source of truth for the platform's reference timezone, language,
* currency, and display formats. `resolveExecutionContext` reads `timezone`
* and `locale` from here (cascade: platform default → global → tenant) onto
* every `ExecutionContext`, so formulas (`today()`), analytics date bucketing,
* and rendered `datetime` instants all resolve against the org's region.
* country and currency. `resolveLocalizationContext` (`@objectstack/core`)
* reads `timezone`, `locale` and `currency` from here (cascade: platform
* default → global → tenant) onto every `ExecutionContext`, so formulas
* (`today()`), analytics date bucketing, and rendered `datetime` instants all
* resolve against the org's region.
*
* Display formats follow the locale. How dates, times and numbers are written,
* and which day a week starts on, come from `locale` (language and region), the
* way Salesforce derives them from a user's Locale. Four separate settings for
* them (`date_format`, `time_format`, `number_format`, `first_day_of_week`)
* were offered here until #21958 and nothing ever read them; they were retired
* rather than implemented (the ruling recorded on objectui#11675). Removing
* them is why `version` is 2: the spec's `SettingsManifest.version` contract is
* "increment when keys are renamed/removed". A `sys_setting` row stored for one
* of them is kept, never deleted. The service no longer resolves it (it does
* not appear in `GET /api/settings/localization`), and a write naming the key
* is refused `400 UNKNOWN_KEY`, the answer every undeclared key gets.
*
* Scope is `tenant`: one org per physical tenant (ADR-0002) sets its regional
* defaults; the manifest `default` of each key is the platform built-in, and a
Expand All @@ -18,10 +31,10 @@ import type { SettingsManifest } from '@objectstack/spec/system';
*/
export const localizationSettingsManifest: SettingsManifest = {
namespace: 'localization',
version: 1,
version: 2,
label: 'Localization',
icon: 'Globe',
description: 'Default timezone, language, currency, and date/number formats.',
description: 'Default timezone, language, country, currency, and fiscal year.',
scope: 'tenant',
readPermission: 'setup.access',
writePermission: 'setup.write',
Expand Down Expand Up @@ -78,45 +91,6 @@ export const localizationSettingsManifest: SettingsManifest = {
valueDomain: 'iso_3166_alpha2',
},

// ── Formats ───────────────────────────────────────────────────────────
{ type: 'group', id: 'formats', label: 'Formats', required: false },
{
type: 'select', key: 'date_format', label: 'Date format', required: false, default: 'YYYY-MM-DD',
options: [
{ value: 'YYYY-MM-DD', label: '2026-06-17 (ISO)' },
{ value: 'MM/DD/YYYY', label: '06/17/2026 (US)' },
{ value: 'DD/MM/YYYY', label: '17/06/2026 (EU)' },
{ value: 'DD.MM.YYYY', label: '17.06.2026' },
{ value: 'DD-MMM-YYYY', label: '17-Jun-2026' },
],
},
{
type: 'select', key: 'time_format', label: 'Time format', required: false, default: '24h',
options: [
{ value: '24h', label: '24-hour (14:30)' },
{ value: '12h', label: '12-hour (2:30 PM)' },
],
},
{
type: 'select', key: 'number_format', label: 'Number format', required: false, default: '1,234.56',
description: 'Grouping and decimal separators for displayed numbers.',
options: [
{ value: '1,234.56', label: '1,234.56 (comma / dot)' },
{ value: '1.234,56', label: '1.234,56 (dot / comma)' },
{ value: '1 234,56', label: '1 234,56 (space / comma)' },
{ value: '1,23,456.78', label: '1,23,456.78 (Indian)' },
],
},
{
type: 'select', key: 'first_day_of_week', label: 'First day of week', required: false, default: 'monday',
description: 'Anchors weekly analytics buckets and calendar grids.',
options: [
{ value: 'monday', label: 'Monday (ISO)' },
{ value: 'sunday', label: 'Sunday' },
{ value: 'saturday', label: 'Saturday' },
],
},

// ── Finance ───────────────────────────────────────────────────────────
{ type: 'group', id: 'finance', label: 'Finance', required: false },
{
Expand Down
95 changes: 95 additions & 0 deletions packages/services/service-settings/src/settings-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,98 @@ describe('settings-routes — #7522 encrypted values are redacted at the REST bo
);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// #21958 — `date_format`, `time_format`, `number_format` and `first_day_of_week`
// left the Localization manifest (ruled on objectui#11675: formats follow the
// locale). These pin what the REST surface does with a value a workspace
// stored for one of them before the upgrade:
//
// - read: not offered and not resolved — absent from the served manifest
// (which is what the Setup page renders) and from `values`;
// - write: refused `400 UNKNOWN_KEY`, naming the key, like any undeclared key;
// - the stored row is KEPT: no read, write or reset path rewrites or deletes it,
// and it never blocks a save of the keys that remain.
//
// The rows are seeded store-level, the way `seedGlobalSecret` above seeds one:
// after the upgrade no public write path can produce them.
// ─────────────────────────────────────────────────────────────────────────────

const RETIRED_FORMAT_ROWS: Record<string, string> = {
date_format: 'DD.MM.YYYY',
time_format: '12h',
number_format: '1.234,56',
first_day_of_week: 'sunday',
};

async function makeRetiredFormatStack() {
const svc = new SettingsService({ env: {} });
svc.registerManifest(localizationSettingsManifest);
for (const [key, value] of Object.entries(RETIRED_FORMAT_ROWS)) {
await (svc as any).upsertRow({
namespace: 'localization', key, scope: 'tenant', user_id: null,
value, value_enc: null, encrypted: false,
});
}
const http = new MockHttp();
registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider });
/** The store's own rows for the four keys, read the way every resolve reads them. */
const storedFormatRows = async () => {
const rows = (await (svc as any).loadRows('localization', null)) as Array<{ key: string; value: unknown }>;
return Object.fromEntries(rows.filter((r) => r.key in RETIRED_FORMAT_ROWS).map((r) => [r.key, r.value]));
};
return { svc, http, storedFormatRows };
}

describe('settings-routes — the four retired Localization format keys (#21958)', () => {
it('GET /api/settings/localization offers none of them, even with a stored value', async () => {
const { http, storedFormatRows } = await makeRetiredFormatStack();
const { req, res, state } = makeReqRes({ params: { namespace: 'localization' } });
await http.routes.get('GET /api/settings/:namespace')!(req, res);

expect(state.status).toBe(200);
const specifiers = state.body.data.manifest.specifiers as Array<{ key?: string; id?: string }>;
const offered = specifiers.map((s) => s.key).filter(Boolean);
for (const key of Object.keys(RETIRED_FORMAT_ROWS)) {
expect(offered, `${key} must not be offered`).not.toContain(key);
expect(Object.keys(state.body.data.values), `${key} must not be resolved`).not.toContain(key);
}
expect(specifiers.map((s) => s.id)).not.toContain('formats');
// The five live keys still answer.
expect(Object.keys(state.body.data.values).sort()).toEqual(
['currency', 'default_country', 'fiscal_year_start', 'locale', 'timezone'],
);
expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
});

it('PUT naming one of them is refused 400 UNKNOWN_KEY, and the stored row is untouched', async () => {
const { http, storedFormatRows } = await makeRetiredFormatStack();
for (const key of Object.keys(RETIRED_FORMAT_ROWS)) {
const { req, res, state } = makeReqRes({
params: { namespace: 'localization' },
// A live key in the same batch: the refusal is whole-batch, nothing lands.
body: { [key]: 'anything', timezone: 'Europe/Paris' },
});
await http.routes.get('PUT /api/settings/:namespace')!(req, res);
expect(state.status).toBe(400);
expect(state.body.error.code).toBe('UNKNOWN_KEY');
expect(state.body.error.details).toEqual({ namespace: 'localization', key });
}
expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
});

it('a stored retired row never blocks saving the live keys, and reset leaves it in place', async () => {
const { svc, http, storedFormatRows } = await makeRetiredFormatStack();
const put = makeReqRes({ params: { namespace: 'localization' }, body: { timezone: 'Europe/Paris' } });
await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res);
expect(put.state.status).toBe(200);
expect(put.state.body.data.values.timezone.value).toBe('Europe/Paris');

const reset = makeReqRes({ params: { namespace: 'localization', actionId: 'reset' }, body: {} });
await http.routes.get('POST /api/settings/:namespace/:actionId')!(reset.req, reset.res);
expect(reset.state.status).toBe(200);
expect((await svc.get('localization', 'timezone')).source).toBe('default');

expect(await storedFormatRows()).toEqual(RETIRED_FORMAT_ROWS);
});
});
13 changes: 7 additions & 6 deletions packages/services/service-settings/src/settings-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2200,7 +2200,8 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
// The registry-backed shape (`mail.provider`, `sms.provider`): its table
// IS the supported set, and declaring no domain must keep it that way,
// byte-for-byte. Pinned on the mail manifest itself plus a localization
// key that deliberately declares no domain.
// key that deliberately declares no domain (`fiscal_year_start`, a closed
// table of twelve months).
const svc = new SettingsService({ env: {} });
svc.registerManifest(mailSettingsManifest);
await expect(svc.setMany('mail', { provider: 'sendgrid' })).rejects.toMatchObject({
Expand All @@ -2209,9 +2210,9 @@ describe('SettingsService — a declared valueDomain is the save-time boundary (
});

const loc = localizationService();
await expect(loc.setMany('localization', { first_day_of_week: 'thursday' })).rejects.toMatchObject({
await expect(loc.setMany('localization', { fiscal_year_start: 'smarch' })).rejects.toMatchObject({
code: 'SETTINGS_VALIDATION',
fields: [{ field: 'first_day_of_week', code: 'invalid_option' }],
fields: [{ field: 'fiscal_year_start', code: 'invalid_option' }],
});
});

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

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

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