Skip to content

Commit 9afeb2d

Browse files
os-zhuangclaude
andauthored
feat(settings): localization settings — platform timezone/language/formats (ADR-0053 Phase 2) (#2006)
Adds a `localization` SettingsManifest — the keystone that makes the Phase 2 reference timezone configurable end-to-end. One manifest gives the full settings stack: platform default → global → tenant cascade + a permission-gated settings page + i18n (en/zh-CN). Keys (organization-level; per-user overrides out of scope for v1): timezone, locale, default_country, date_format, time_format, number_format, first_day_of_week, currency, fiscal_year_start. Benchmarked vs Salesforce/ Workday "Company Information + Locale". - resolver 收编: resolveExecutionContext resolves timezone AND locale from the localization settings via the `settings` service (4-tier cascade), falling back to a direct tenant-scoped sys_setting read, then UTC/en-US. Replaces the hand-rolled sys_user_preference + tenant-only path from #1978 (drops the per-user tier). New ExecutionContext.locale. - analytics wiring: DatasetExecutor threads ExecutionContext.timezone into the query (selection tz → request tz → UTC), so #1982 tz buckets fire for a configured org. Formula today()/datetime already wired (#1979/#1980). - email datetime wiring deferred: only current sendTemplate callers are pre-session auth emails with no org context (SendTemplateInput.timezone exists from #1981 for business-notification callers). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3029236 commit 9afeb2d

10 files changed

Lines changed: 380 additions & 50 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/service-settings": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/spec": minor
5+
"@objectstack/service-analytics": minor
6+
---
7+
8+
feat(settings): `localization` settings — platform default timezone, language & formats (ADR-0053 Phase 2)
9+
10+
Adds a `localization` SettingsManifest, the missing keystone that makes the Phase 2 reference-timezone actually configurable end-to-end. One declaration gives the full settings stack for free: platform built-in default → `global``tenant` cascade, a permission-gated settings page, and i18n.
11+
12+
**Keys** (organization-level; per-user overrides intentionally out of scope for v1): `timezone` (UTC), `locale` (en-US), `default_country`, `date_format`, `time_format`, `number_format`, `first_day_of_week`, `currency` (USD), `fiscal_year_start`. Benchmarked against Salesforce/Workday "Company Information + Locale".
13+
14+
**Resolver 收编**`resolveExecutionContext` now resolves `timezone` **and** `locale` from the `localization` settings via the `settings` service (canonical 4-tier cascade), falling back to a direct tenant-scoped `sys_setting` read, then `UTC` / `en-US`. This replaces the hand-rolled `sys_user_preference` + tenant-only `sys_setting` path from #1978 (which bypassed the settings abstraction and is dropped along with the per-user tier). New `ExecutionContext.locale`.
15+
16+
**Consumer wiring** — analytics date bucketing now picks up the resolved org timezone: `DatasetExecutor` threads `ExecutionContext.timezone` into the query (precedence: explicit selection tz → request tz → UTC), so #1982's tz-aware buckets fire for a configured org without callers passing a zone. Formula `today()`/`datetime` were already wired (#1979/#1980).
17+
18+
Email `datetime` rendering (`SendTemplateInput.timezone`, shipped in #1981) is intentionally **not** wired here: the only current `sendTemplate` callers are pre-session auth emails with no org context; business-notification callers can pass the zone when they appear.

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,31 @@ describe('resolveExecutionContext — API key verify path', () => {
143143
});
144144

145145
/**
146-
* Reference-timezone resolution (ADR-0053 Phase 2, #1978): user preference →
147-
* org default → UTC. Authenticate via API key so a userId is present, then
148-
* seed `sys_user_preference` / `sys_setting` and assert `ctx.timezone`.
146+
* Localization resolution (ADR-0053 Phase 2): reference `timezone` + `locale`
147+
* resolved from the `localization` settings. Canonical path is the `settings`
148+
* service (platform default → global → tenant); when it's absent the resolver
149+
* falls back to a direct tenant-scoped `sys_setting` read, then UTC / en-US.
150+
* Per-user overrides are intentionally out of scope (organization-level only),
151+
* so `sys_user_preference` is no longer consulted.
149152
*/
150-
describe('resolveExecutionContext — reference timezone (#1978)', () => {
153+
describe('resolveExecutionContext — localization (timezone + locale)', () => {
151154
const RAW = 'osk_tz';
152155
const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', expires_at: FUTURE }];
153156

154-
function makeTzOpts({ prefs = [], settings = [] }: { prefs?: any[]; settings?: any[] }) {
157+
/** Fake `settings` service doing the 4-tier `get(namespace, key)` resolution. */
158+
function makeSettings(values: Record<string, unknown>) {
159+
return {
160+
async get(namespace: string, key: string) {
161+
return { value: values[`${namespace}.${key}`], source: 'tenant' };
162+
},
163+
};
164+
}
165+
166+
function makeTzOpts({
167+
settings = [],
168+
prefs = [],
169+
settingsService,
170+
}: { settings?: any[]; prefs?: any[]; settingsService?: any }) {
155171
const tables: Record<string, any[]> = {
156172
sys_api_key: apiKeyRows,
157173
sys_user_preference: prefs,
@@ -171,48 +187,64 @@ describe('resolveExecutionContext — reference timezone (#1978)', () => {
171187
},
172188
};
173189
return {
174-
getService: async () => undefined,
190+
getService: async (name: string) => (name === 'settings' ? settingsService : undefined),
175191
getQl: async () => ql,
176192
request: { headers: { 'x-api-key': RAW } },
177193
};
178194
}
179195

180-
it('prefers the user preference over the org default', async () => {
196+
it('resolves timezone + locale via the settings service when present', async () => {
181197
const ctx = await resolveExecutionContext(makeTzOpts({
182-
prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }],
183-
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
198+
settingsService: makeSettings({
199+
'localization.timezone': 'Europe/Paris',
200+
'localization.locale': 'zh-CN',
201+
}),
184202
}));
185203
expect(ctx.userId).toBe('u1');
186-
expect(ctx.timezone).toBe('America/New_York');
204+
expect(ctx.timezone).toBe('Europe/Paris');
205+
expect(ctx.locale).toBe('zh-CN');
187206
});
188207

189-
it('falls back to the tenant-scoped org default when no user preference', async () => {
208+
it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => {
190209
const ctx = await resolveExecutionContext(makeTzOpts({
210+
settings: [
211+
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
212+
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
213+
],
214+
}));
215+
expect(ctx.timezone).toBe('Asia/Tokyo');
216+
expect(ctx.locale).toBe('ja-JP');
217+
});
218+
219+
it('ignores per-user sys_user_preference rows (organization-level only)', async () => {
220+
const ctx = await resolveExecutionContext(makeTzOpts({
221+
prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }],
191222
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
192223
}));
193-
expect(ctx.timezone).toBe('Europe/Paris');
224+
expect(ctx.timezone).toBe('Europe/Paris'); // org default, NOT the user pref
194225
});
195226

196-
it('defaults to UTC when neither is set', async () => {
227+
it('defaults to UTC / en-US when nothing is configured', async () => {
197228
const ctx = await resolveExecutionContext(makeTzOpts({}));
198229
expect(ctx.timezone).toBe('UTC');
230+
expect(ctx.locale).toBe('en-US');
199231
});
200232

201-
it('ignores an invalid zone and continues down the chain', async () => {
233+
it('ignores an invalid zone and falls back to the built-in', async () => {
202234
const ctx = await resolveExecutionContext(makeTzOpts({
203-
prefs: [{ user_id: 'u1', key: 'timezone', value: 'Not/AZone' }],
204-
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }],
235+
settingsService: makeSettings({ 'localization.timezone': 'Not/AZone' }),
205236
}));
206-
expect(ctx.timezone).toBe('Asia/Tokyo');
237+
expect(ctx.timezone).toBe('UTC');
207238
});
208239

209-
it('leaves timezone unset for anonymous requests', async () => {
240+
it('leaves timezone/locale unset for anonymous requests', async () => {
210241
const ctx = await resolveExecutionContext({
211242
getService: async () => undefined,
212243
getQl: async () => ({ async find() { return []; } }),
213244
request: { headers: {} },
214245
});
215246
expect(ctx.userId).toBeUndefined();
216247
expect(ctx.timezone).toBeUndefined();
248+
expect(ctx.locale).toBeUndefined();
217249
});
218250
});

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -84,35 +84,57 @@ function coerceTimeZone(value: unknown): string | undefined {
8484
return s && isValidTimeZone(s) ? s : undefined;
8585
}
8686

87+
/** Coerce a stored locale value to a non-empty BCP-47-ish string, or undefined. */
88+
function coerceLocale(value: unknown): string | undefined {
89+
const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : '';
90+
return s || undefined;
91+
}
92+
8793
/**
88-
* Resolve the active reference timezone for an authenticated context
89-
* (ADR-0053 Phase 2): user preference → org default → `UTC`.
94+
* Resolve the workspace localization defaults onto the ExecutionContext
95+
* (ADR-0053 Phase 2): reference `timezone` and `locale`.
9096
*
91-
* - User override: `sys_user_preference` row `(user_id, key='timezone')`.
92-
* - Org default: the tenant-scoped `sys_setting` `(namespace='localization',
93-
* key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002),
94-
* so the row needs no tenant_id filter.
97+
* Canonical path is the `localization` SettingsManifest via the `settings`
98+
* service, whose cascade is platform default → global → tenant (ADR-0002: one
99+
* org per physical tenant; per-user overrides are intentionally out of scope
100+
* for v1). When the settings service or its namespace is unavailable (e.g. a
101+
* minimal deployment), fall back to a direct tenant-scoped `sys_setting` read,
102+
* then to the built-ins `UTC` / `en-US`.
95103
*
96-
* Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent
97-
* value resolves to `UTC` and preserves today's behavior. Every read is
98-
* defensive (via `tryFind`) and an invalid zone falls through — timezone
99-
* resolution never blocks auth.
104+
* Every read is defensive — localization never blocks auth, and an invalid
105+
* zone falls through to the built-in.
100106
*/
101-
async function resolveTimezone(ql: any, userId: string): Promise<string> {
102-
const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1);
103-
const userTz = coerceTimeZone(prefRows[0]?.value);
104-
if (userTz) return userTz;
105-
106-
const settingRows = await tryFind(
107-
ql,
108-
'sys_setting',
109-
{ namespace: 'localization', key: 'timezone', scope: 'tenant' },
110-
1,
111-
);
112-
const orgTz = coerceTimeZone(settingRows[0]?.value);
113-
if (orgTz) return orgTz;
107+
async function resolveLocalization(
108+
opts: ResolveOptions,
109+
ql: any,
110+
sctx: { tenantId?: string; userId?: string },
111+
): Promise<{ timezone: string; locale: string }> {
112+
// 1. Canonical — the `localization` manifest via the settings service.
113+
try {
114+
const settings: any = await opts.getService('settings');
115+
if (settings && typeof settings.get === 'function') {
116+
const [tzRes, localeRes] = await Promise.all([
117+
settings.get('localization', 'timezone', sctx).catch(() => undefined),
118+
settings.get('localization', 'locale', sctx).catch(() => undefined),
119+
]);
120+
const tz = coerceTimeZone(tzRes?.value);
121+
const locale = coerceLocale(localeRes?.value);
122+
// A resolved value (incl. the manifest default) means the namespace is
123+
// live — trust it and skip the legacy direct read.
124+
if (tz || locale) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US' };
125+
}
126+
} catch {
127+
// settings service unavailable → fall through to the direct read
128+
}
114129

115-
return 'UTC';
130+
// 2. Fallback — direct tenant-scoped `sys_setting` rows (no settings service
131+
// registered, or namespace not loaded).
132+
const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1);
133+
const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1);
134+
return {
135+
timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC',
136+
locale: coerceLocale(localeRows[0]?.value) ?? 'en-US',
137+
};
116138
}
117139

118140
/**
@@ -310,10 +332,13 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
310332
}
311333
}
312334

313-
// 4. Reference timezone (ADR-0053 Phase 2) — resolved once per request and
314-
// carried on the context. No consumer reads it yet; absent config → 'UTC'
315-
// keeps current behavior.
316-
ctx.timezone = await resolveTimezone(ql, userId);
335+
// 4. Localization (ADR-0053 Phase 2) — reference timezone + locale resolved
336+
// once per request from the `localization` settings and carried on the
337+
// context. Consumers: formula today()/datetime, analytics date buckets,
338+
// email rendering. Absent config → UTC / en-US keeps current behavior.
339+
const localization = await resolveLocalization(opts, ql, { tenantId, userId });
340+
ctx.timezone = localization.timezone;
341+
ctx.locale = localization.locale;
317342

318343
return ctx;
319344
}

packages/services/service-analytics/src/dataset-executor.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export class DatasetExecutor {
214214
dimensions,
215215
where: baseFilter,
216216
selection,
217+
contextTimezone: context?.timezone,
217218
}), context);
218219
} else {
219220
result = { rows: [], fields: [] };
@@ -224,6 +225,7 @@ export class DatasetExecutor {
224225
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
225226
const sub = await this.service.query(this.buildQuery(compiled, {
226227
measures: [m], dimensions, where: mFilter, selection,
228+
contextTimezone: context?.timezone,
227229
}), context);
228230
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
229231
result.fields.push({ name: m, type: 'number' });
@@ -255,13 +257,16 @@ export class DatasetExecutor {
255257
dimensions: string[];
256258
where?: FilterCondition;
257259
selection: DatasetSelection;
260+
contextTimezone?: string;
258261
},
259262
): AnalyticsQuery {
260263
const q: AnalyticsQuery = {
261264
cube: compiled.cube.name,
262265
measures: opts.measures,
263266
dimensions: opts.dimensions,
264-
timezone: opts.selection.timezone ?? 'UTC',
267+
// Precedence: explicit selection tz → request's reference tz
268+
// (ExecutionContext.timezone, ADR-0053 Phase 2) → UTC.
269+
timezone: opts.selection.timezone ?? opts.contextTimezone ?? 'UTC',
265270
};
266271
if (opts.where) q.where = opts.where as Record<string, unknown>;
267272
// Bucket selected date dimensions that declare an explicit `dateGranularity`
@@ -314,7 +319,7 @@ export class DatasetExecutor {
314319
dimensions,
315320
where: baseFilter as Record<string, unknown> | undefined,
316321
timeDimensions: shiftedTd,
317-
timezone: selection.timezone ?? 'UTC',
322+
timezone: selection.timezone ?? context?.timezone ?? 'UTC',
318323
}, context);
319324
// Rename measure columns to `<measure>__compare` so they merge alongside primary.
320325
return sub.rows.map((row) => {

packages/services/service-settings/src/manifests/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
aiTestEmbedderActionHandler,
1313
} from './ai.manifest.js';
1414
export { knowledgeSettingsManifest, knowledgeTestActionHandler } from './knowledge.manifest.js';
15+
export { localizationSettingsManifest } from './localization.manifest.js';
1516

1617
import { authSettingsManifest } from './auth.manifest.js';
1718
import { mailSettingsManifest } from './mail.manifest.js';
@@ -20,10 +21,12 @@ import { featureFlagsSettingsManifest } from './feature-flags.manifest.js';
2021
import { storageSettingsManifest } from './storage.manifest.js';
2122
import { aiSettingsManifest } from './ai.manifest.js';
2223
import { knowledgeSettingsManifest } from './knowledge.manifest.js';
24+
import { localizationSettingsManifest } from './localization.manifest.js';
2325

2426
/** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */
2527
export const builtinSettingsManifests = [
2628
brandingSettingsManifest,
29+
localizationSettingsManifest,
2730
authSettingsManifest,
2831
mailSettingsManifest,
2932
storageSettingsManifest,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SettingsManifestSchema } from '@objectstack/spec/system';
5+
import { localizationSettingsManifest } from './localization.manifest';
6+
7+
describe('localizationSettingsManifest', () => {
8+
it('parses against SettingsManifestSchema', () => {
9+
expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow();
10+
});
11+
12+
it('declares namespace=localization, scope=tenant, version=1', () => {
13+
const parsed = SettingsManifestSchema.parse(localizationSettingsManifest);
14+
expect(parsed.namespace).toBe('localization');
15+
expect(parsed.scope).toBe('tenant'); // 组织级 (per-user overrides out of scope for v1)
16+
expect(parsed.version).toBe(1);
17+
});
18+
19+
it('defaults to UTC / en-US — preserving pre-Phase-2 behavior when nothing is set', () => {
20+
const specs = localizationSettingsManifest.specifiers as any[];
21+
const byKey = (k: string) => specs.find((s) => s.key === k);
22+
expect(byKey('timezone').default).toBe('UTC');
23+
expect(byKey('locale').default).toBe('en-US');
24+
expect(byKey('currency').default).toBe('USD');
25+
expect(byKey('date_format').default).toBe('YYYY-MM-DD');
26+
expect(byKey('first_day_of_week').default).toBe('monday');
27+
expect(byKey('fiscal_year_start').default).toBe('january');
28+
});
29+
30+
it('every timezone option is a valid IANA zone', () => {
31+
const tz = (localizationSettingsManifest.specifiers as any[]).find((s) => s.key === 'timezone');
32+
for (const opt of tz.options) {
33+
expect(
34+
() => new Intl.DateTimeFormat('en-US', { timeZone: String(opt.value) }),
35+
).not.toThrow();
36+
}
37+
});
38+
39+
it('exposes the nine regional keys grouped into region/formats/finance', () => {
40+
const specs = localizationSettingsManifest.specifiers as any[];
41+
const keys = specs.filter((s) => s.key).map((s) => s.key);
42+
expect(keys).toEqual([
43+
'timezone', 'locale', 'default_country',
44+
'date_format', 'time_format', 'number_format', 'first_day_of_week',
45+
'currency', 'fiscal_year_start',
46+
]);
47+
const groups = specs.filter((s) => s.type === 'group').map((s) => s.id);
48+
expect(groups).toEqual(['region', 'formats', 'finance']);
49+
});
50+
});

0 commit comments

Comments
 (0)