Skip to content

Commit 0d8ea5e

Browse files
fix(service-settings)!: retire date_format, time_format, number_format and first_day_of_week from the Localization manifest (#21958) (#21970)
Fixes #21958 Clause-②: no (narrowing) Executes the maintainer's ruling recorded on objectstack-ai/objectui#11675 (comment 6010771237, decision batch 2, item 1, B, verbatim 「同意」): dates, times, numbers and the first day of the week follow the user's locale, and `date_format`, `time_format`, `number_format` and `first_day_of_week` leave the Localization settings without being implemented. objectui#11675 remains open for its own remainder (the calendar and timeline week start); this PR does not touch it. ## What changes `packages/services/service-settings` only: - `src/manifests/localization.manifest.ts`: the four specifiers leave, with the Formats group they made up (it would have been an empty header). `timezone`, `locale`, `default_country`, `currency` and `fiscal_year_start` are unchanged. `version` goes from 1 to 2, because the spec's `SettingsManifestSchema.version` contract reads "Manifest version. Increment when keys are renamed/removed." The namespace description and the module docblock say what replaced the four. While rewriting that docblock I corrected its stale function name (`resolveExecutionContext` no longer exists; `resolveLocalizationContext` in `@objectstack/core` is what reads `timezone`, `locale` and `currency`). - `src/translations/{en,es-ES,ja-JP,zh-CN}.ts`: the four key entries and the `formats` group leave each bundle, and the namespace description follows the manifest. - Tests: see below. - `.changeset/21958-retire-locale-format-settings.md`: `@objectstack/service-settings` minor, BREAKING banner, `Clause-②: no (narrowing)`, ADR-0087 disposition `not-required (no-migration-prescription)`. The four were settings values in `sys_setting`, not metadata, so `objectstack migrate meta` has nothing to rewrite. No `packages/spec`, `skills/**` or `content/docs/releases/` edit. ## Stored values: measured, not assumed Measured twice through the real REST handlers (`registerSettingsRoutes` over `SettingsService`): on `origin/main` `80f9f7e6ba`, with a copy of the manifest minus the four registered over a store that already held a `date_format` row, and on this branch with the shipped manifest. Both gave the same answers: | Door | Result after the retirement | |---|---| | `GET /api/settings/localization` | 200. The four are absent from `manifest.specifiers` and from `values`; the stored row is loaded with the namespace but never resolved. | | `PUT /api/settings/localization` naming one of them | `400 UNKNOWN_KEY`, `details: { namespace: 'localization', key }`. The refusal covers the whole batch: a `timezone` sent beside it does not land. | | `PUT` of a live key, with the stale row in the store | 200. The stale row never blocks a save. | | built-in `reset` action | 200, "Cleared 1 saved value(s)": it clears the live key and leaves the retired row alone. | | in process, `settings.get('localization', 'date_format')` | rejects `UnknownKeyError`, `code: 'SETTINGS_UNKNOWN_KEY'`. | | registering the old manifest shape again (a rollback) | reads `DD.MM.YYYY`, source `tenant`: the row survived every step above. | | `OS_LOCALIZATION_DATE_FORMAT` (and the other three) | no longer read, and nothing is logged at registration. Before, it set a value that nothing read. | So a stored row is kept, never deleted: ignored on read, refused on write. No service code changed. This is the path every undeclared key already takes. **Precedent, as measured.** The dispatch pointed at `settings-routes.ts:25` and at `settings-service.ts` near line 920. Line 25 cites envelope siblings retired from the route module, not a settings key. Line 920 is the retired `mail.provider` OPTION values (`sendgrid`, `ses`), where a stored stale value is kept and judged only when written (the TOUCH gate). No settings key had ever left a manifest before this one. I checked every commit whose diff to a `*manifest*.ts` outside `packages/spec` touches a `key:` line (26 commits, on an unshallowed history of 15,821): no key disappears in any of them, and no manifest's `version` was ever above 1. Two contracts already in place therefore answer the question: the generic undeclared-key path above, and the spec's own `version` rule. A retirement entry would have needed a home in `packages/spec`, and nothing requires one there. `SettingsManifest.version` has no reader in objectstack or objectui at `main` (grep for `manifest.version` in the settings code of both), so the bump is compliance with the declared contract and changes no behaviour. ## Docs No `content/docs/**` page lists the four as settings. Searched outside `references/` and `releases/` for the four key names and for "Date format", "Time format", "Number format", "First day of week", "Localization settings" and "date/number formats". The only hits are field-type and i18n-API pages describing other things (`data-modeling/validation-rules.mdx`, `protocol/kernel/i18n-standard.mdx`, `protocol/kernel/index.mdx`). No page needed an edit. The repo-wide reader sweep for the four key names finds only SQL's own `date_format()` (driver-sql, comments in core and objectql, and the two `skills/**` rule files, which describe the SQL function), and the export `transform` enum value `'date_format'`. None of these is the settings key. ## Tests (head `397edabf04`) - New pins in `src/settings-routes.test.ts`: four retired rows are seeded in the store, as `seedGlobalSecret` in the same file seeds one. Then: - `GET` offers and resolves none of them; - `PUT` naming each is refused, asserting `status` 400, `error.code` `UNKNOWN_KEY` and `error.details`; - a live-key save and the `reset` action both leave the four stored rows byte-equal. - `src/manifests/localization.manifest.test.ts`: `version` is 2; the key list is five keys in `region`/`finance`. A new negative pin asserts that none of the four, nor the `formats` group, is offered. - `src/translations/settings-translation-coverage.test.ts` now also checks the other direction, for `en` and the three translated bundles: no copy for a group or key that no manifest declares. It measured zero such entries across all ten manifests before it was added, so it closes the class rather than pinning only these four. - `src/settings-service.test.ts`: the two "domain-less select keeps exhaustive options" pins used `first_day_of_week` and `date_format` as their example key. They now use `fiscal_year_start`, also a domain-less select, so they pin the same behaviour. - `pnpm --filter @objectstack/service-settings test`: 36 files, 626 tests passed. `typecheck` (`tsc --noEmit`) exit 0; `--listFiles` shows it compiles 36 test files, including all four edited ones. The dependency closure (`--filter '@objectstack/service-settings^...'`) was built first. - The built `dist` loads through both conditions: `require('./dist/index.cjs')` and `import('./dist/index.js')` each serve `version 2` with five keys. ## Ablation (negative pins proven able to fail) Both ablations used `scripts/ablation-replace.mjs` (literal anchor that must hit; blob hashes verified; restore by blob to HEAD, `git diff HEAD` empty): 1. **A retired row put back into the manifest** (`date_format` re-inserted, on-disk count 1). 8 tests went red: the three new route pins, the two manifest pins, and zh-CN, ja-JP and es-ES coverage. The tree was restored to blob `e8f3039af917` (equal to HEAD). A first attempt was refused by the tool, which is why there were two: the replacement contained its own anchor, so the anchor count did not drop. Nothing was measured on that attempt. 2. **A translation entry put back** (`date_format` re-added to `en.ts` with no manifest row). The new reverse-direction pin went red with `en has undeclared settings copy: localization.key:date_format`. Restored to blob `0b0918e9ece5` (equal to HEAD). ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the final tree derived 63 commands. That is the dispatch's 49 plus 14 that the changeset and test edits pull in, including `check-adr-0087-registration`, `check-empty-changeset`, `check:engine-double-contract` and `check:type-check-coverage`. All 63 were run at `397edabf04` with exit codes captured before any pipe. 62 exited 0. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (53 packages outside this closure have no `dist/`): **NOT MEASURED** locally, and CI's full build runs it. The `--ran` reconciliation answers "63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN". ESLint, narrowed: `pnpm exec eslint --no-inline-config --format json` over the 9 touched `.ts` files gave 9 files linted, 0 errors and 0 warnings. All 9 are inside the config's population (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus the build dirs). The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes (not filed) - An `OS_LOCALIZATION_*` variable naming a retired key is now silently unread. No namespace audits `OS_` variables that name an undeclared key (a typo such as `OS_LOCALIZATION_TIMEZON` is equally silent today). Pre-existing and general, and the value was never read before either. Carrier: none. - `SettingsManifest.version` has no reader anywhere measured, so the "increment when keys are removed" contract is honoured without being enforced. No pull. Carrier: none. - objectui's `apps/console/src/pages/settings/__tests__/SettingsField.valueDomain.test.tsx` uses `date_format` and `first_day_of_week` as hand-built fixture keys. It does not import this manifest, so it stays green and is harmless. Carrier: whoever next edits that file, possibly with objectui#11675's remainder. - `@objectstack/service-settings`' exported types are byte-unchanged (the manifest is typed `SettingsManifest`, and only its value moved), and no package outside this one imports `localizationSettingsManifest` or the bundles in code. So there is no downstream consumer sweep. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9761cd commit 0d8ea5e

10 files changed

Lines changed: 210 additions & 106 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**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.
12+
13+
**A value a workspace already stored for one of the four is kept.** Measured at the REST surface:
14+
15+
- The `sys_setting` row stays exactly as it was. No read, save or reset rewrites or deletes it.
16+
- `GET /api/settings/localization` does not resolve it: the key is absent from both `manifest.specifiers` and `values`.
17+
- 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.
18+
- A stored row never blocks saving the live keys, and the built-in `reset` action leaves it in place.
19+
- In process, `settings.get('localization', 'date_format')` (or any of the four) now rejects with `SETTINGS_UNKNOWN_KEY`.
20+
- 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.
21+
22+
**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.
23+
24+
It ships as `minor` under the launch-window convention for narrowings.

‎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');

0 commit comments

Comments
 (0)