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
18 changes: 18 additions & 0 deletions .changeset/localization-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/service-settings": minor
"@objectstack/runtime": minor
"@objectstack/spec": minor
"@objectstack/service-analytics": minor
---

feat(settings): `localization` settings — platform default timezone, language & formats (ADR-0053 Phase 2)

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.

**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".

**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`.

**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).

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.
68 changes: 50 additions & 18 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,31 @@ describe('resolveExecutionContext — API key verify path', () => {
});

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

function makeTzOpts({ prefs = [], settings = [] }: { prefs?: any[]; settings?: any[] }) {
/** Fake `settings` service doing the 4-tier `get(namespace, key)` resolution. */
function makeSettings(values: Record<string, unknown>) {
return {
async get(namespace: string, key: string) {
return { value: values[`${namespace}.${key}`], source: 'tenant' };
},
};
}

function makeTzOpts({
settings = [],
prefs = [],
settingsService,
}: { settings?: any[]; prefs?: any[]; settingsService?: any }) {
const tables: Record<string, any[]> = {
sys_api_key: apiKeyRows,
sys_user_preference: prefs,
Expand All @@ -171,48 +187,64 @@ describe('resolveExecutionContext — reference timezone (#1978)', () => {
},
};
return {
getService: async () => undefined,
getService: async (name: string) => (name === 'settings' ? settingsService : undefined),
getQl: async () => ql,
request: { headers: { 'x-api-key': RAW } },
};
}

it('prefers the user preference over the org default', async () => {
it('resolves timezone + locale via the settings service when present', async () => {
const ctx = await resolveExecutionContext(makeTzOpts({
prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }],
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
settingsService: makeSettings({
'localization.timezone': 'Europe/Paris',
'localization.locale': 'zh-CN',
}),
}));
expect(ctx.userId).toBe('u1');
expect(ctx.timezone).toBe('America/New_York');
expect(ctx.timezone).toBe('Europe/Paris');
expect(ctx.locale).toBe('zh-CN');
});

it('falls back to the tenant-scoped org default when no user preference', async () => {
it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => {
const ctx = await resolveExecutionContext(makeTzOpts({
settings: [
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
],
}));
expect(ctx.timezone).toBe('Asia/Tokyo');
expect(ctx.locale).toBe('ja-JP');
});

it('ignores per-user sys_user_preference rows (organization-level only)', async () => {
const ctx = await resolveExecutionContext(makeTzOpts({
prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }],
settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }],
}));
expect(ctx.timezone).toBe('Europe/Paris');
expect(ctx.timezone).toBe('Europe/Paris'); // org default, NOT the user pref
});

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

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

it('leaves timezone unset for anonymous requests', async () => {
it('leaves timezone/locale unset for anonymous requests', async () => {
const ctx = await resolveExecutionContext({
getService: async () => undefined,
getQl: async () => ({ async find() { return []; } }),
request: { headers: {} },
});
expect(ctx.userId).toBeUndefined();
expect(ctx.timezone).toBeUndefined();
expect(ctx.locale).toBeUndefined();
});
});
81 changes: 53 additions & 28 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,35 +84,57 @@ function coerceTimeZone(value: unknown): string | undefined {
return s && isValidTimeZone(s) ? s : undefined;
}

/** Coerce a stored locale value to a non-empty BCP-47-ish string, or undefined. */
function coerceLocale(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : '';
return s || undefined;
}

/**
* Resolve the active reference timezone for an authenticated context
* (ADR-0053 Phase 2): user preference → org default → `UTC`.
* Resolve the workspace localization defaults onto the ExecutionContext
* (ADR-0053 Phase 2): reference `timezone` and `locale`.
*
* - User override: `sys_user_preference` row `(user_id, key='timezone')`.
* - Org default: the tenant-scoped `sys_setting` `(namespace='localization',
* key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002),
* so the row needs no tenant_id filter.
* Canonical path is the `localization` SettingsManifest via the `settings`
* service, whose cascade is platform default → global → tenant (ADR-0002: one
* org per physical tenant; per-user overrides are intentionally out of scope
* for v1). When the settings service or its namespace is unavailable (e.g. a
* minimal deployment), fall back to a direct tenant-scoped `sys_setting` read,
* then to the built-ins `UTC` / `en-US`.
*
* Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent
* value resolves to `UTC` and preserves today's behavior. Every read is
* defensive (via `tryFind`) and an invalid zone falls through — timezone
* resolution never blocks auth.
* Every read is defensive — localization never blocks auth, and an invalid
* zone falls through to the built-in.
*/
async function resolveTimezone(ql: any, userId: string): Promise<string> {
const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1);
const userTz = coerceTimeZone(prefRows[0]?.value);
if (userTz) return userTz;

const settingRows = await tryFind(
ql,
'sys_setting',
{ namespace: 'localization', key: 'timezone', scope: 'tenant' },
1,
);
const orgTz = coerceTimeZone(settingRows[0]?.value);
if (orgTz) return orgTz;
async function resolveLocalization(
opts: ResolveOptions,
ql: any,
sctx: { tenantId?: string; userId?: string },
): Promise<{ timezone: string; locale: string }> {
// 1. Canonical — the `localization` manifest via the settings service.
try {
const settings: any = await opts.getService('settings');
if (settings && typeof settings.get === 'function') {
const [tzRes, localeRes] = await Promise.all([
settings.get('localization', 'timezone', sctx).catch(() => undefined),
settings.get('localization', 'locale', sctx).catch(() => undefined),
]);
const tz = coerceTimeZone(tzRes?.value);
const locale = coerceLocale(localeRes?.value);
// A resolved value (incl. the manifest default) means the namespace is
// live — trust it and skip the legacy direct read.
if (tz || locale) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US' };
}
} catch {
// settings service unavailable → fall through to the direct read
}

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

/**
Expand Down Expand Up @@ -310,10 +332,13 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
}
}

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

return ctx;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/services/service-analytics/src/dataset-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export class DatasetExecutor {
dimensions,
where: baseFilter,
selection,
contextTimezone: context?.timezone,
}), context);
} else {
result = { rows: [], fields: [] };
Expand All @@ -224,6 +225,7 @@ export class DatasetExecutor {
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
const sub = await this.service.query(this.buildQuery(compiled, {
measures: [m], dimensions, where: mFilter, selection,
contextTimezone: context?.timezone,
}), context);
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
result.fields.push({ name: m, type: 'number' });
Expand Down Expand Up @@ -255,13 +257,16 @@ export class DatasetExecutor {
dimensions: string[];
where?: FilterCondition;
selection: DatasetSelection;
contextTimezone?: string;
},
): AnalyticsQuery {
const q: AnalyticsQuery = {
cube: compiled.cube.name,
measures: opts.measures,
dimensions: opts.dimensions,
timezone: opts.selection.timezone ?? 'UTC',
// Precedence: explicit selection tz → request's reference tz
// (ExecutionContext.timezone, ADR-0053 Phase 2) → UTC.
timezone: opts.selection.timezone ?? opts.contextTimezone ?? 'UTC',
};
if (opts.where) q.where = opts.where as Record<string, unknown>;
// Bucket selected date dimensions that declare an explicit `dateGranularity`
Expand Down Expand Up @@ -314,7 +319,7 @@ export class DatasetExecutor {
dimensions,
where: baseFilter as Record<string, unknown> | undefined,
timeDimensions: shiftedTd,
timezone: selection.timezone ?? 'UTC',
timezone: selection.timezone ?? context?.timezone ?? 'UTC',
}, context);
// Rename measure columns to `<measure>__compare` so they merge alongside primary.
return sub.rows.map((row) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/services/service-settings/src/manifests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
aiTestEmbedderActionHandler,
} from './ai.manifest.js';
export { knowledgeSettingsManifest, knowledgeTestActionHandler } from './knowledge.manifest.js';
export { localizationSettingsManifest } from './localization.manifest.js';

import { authSettingsManifest } from './auth.manifest.js';
import { mailSettingsManifest } from './mail.manifest.js';
Expand All @@ -20,10 +21,12 @@ import { featureFlagsSettingsManifest } from './feature-flags.manifest.js';
import { storageSettingsManifest } from './storage.manifest.js';
import { aiSettingsManifest } from './ai.manifest.js';
import { knowledgeSettingsManifest } from './knowledge.manifest.js';
import { localizationSettingsManifest } from './localization.manifest.js';

/** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */
export const builtinSettingsManifests = [
brandingSettingsManifest,
localizationSettingsManifest,
authSettingsManifest,
mailSettingsManifest,
storageSettingsManifest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { SettingsManifestSchema } from '@objectstack/spec/system';
import { localizationSettingsManifest } from './localization.manifest';

describe('localizationSettingsManifest', () => {
it('parses against SettingsManifestSchema', () => {
expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow();
});

it('declares namespace=localization, scope=tenant, version=1', () => {
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);
});

it('defaults to UTC / en-US — preserving pre-Phase-2 behavior when nothing is set', () => {
const specs = localizationSettingsManifest.specifiers as any[];
const byKey = (k: string) => specs.find((s) => s.key === k);
expect(byKey('timezone').default).toBe('UTC');
expect(byKey('locale').default).toBe('en-US');
expect(byKey('currency').default).toBe('USD');
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');
});

it('every timezone option is a valid IANA zone', () => {
const tz = (localizationSettingsManifest.specifiers as any[]).find((s) => s.key === 'timezone');
for (const opt of tz.options) {
expect(
() => new Intl.DateTimeFormat('en-US', { timeZone: String(opt.value) }),
).not.toThrow();
}
});

it('exposes the nine regional keys grouped into region/formats/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']);
});
});
Loading
Loading