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
13 changes: 13 additions & 0 deletions .changeset/tz-analytics-bucketing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/service-analytics": patch
"@objectstack/objectql": patch
"@objectstack/rest": patch
---

fix(analytics): make organization timezone actually drive date-dimension bucketing (ADR-0053 Phase 2, #1982)

Date-bucketed analytics silently ignored the reference timezone end-to-end. Three independent seams were broken:

- **service-analytics** — `NativeSQLStrategy` (priority 10) won every cube/dataset query on a SQL driver, but it groups by the raw column (no `date_trunc`) and ignores `timezone`, so a date dimension never bucketed (one row per raw timestamp) and a non-UTC zone was dropped. It now declines queries that carry a `timeDimensions[].granularity`, handing them to `ObjectQLStrategy` → `engine.aggregate` (native bucketing when UTC-safe, uniform in-memory bucketing when non-UTC).
- **objectql** — the in-memory `count` aggregation treated the `*` count-all sentinel (the Cube `count` measure / a fieldless dataset `count`, both compiled to `sql: '*'`) as a column name, counting non-null of a non-existent property → `0` for every bucket. The driver's `COUNT(*)` masked it; the in-memory path (non-UTC date buckets, `driver-rest`/`driver-memory`) returned zeros. `*` is now counted as all rows.
- **rest** — `resolveExecCtx` never resolved the localization timezone/locale, so `/analytics/dataset/query` always ran with `timezone: 'UTC'`. It now resolves them through the `settings` service (honouring the 4-tier cascade incl. the `OS_LOCALIZATION_TIMEZONE` env override), mirroring the dispatcher path.
34 changes: 34 additions & 0 deletions packages/objectql/src/in-memory-aggregation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,37 @@ describe('bucketDateValue', () => {
});
});
});

describe('count-all `*` sentinel (regression #1982)', () => {
// The Cube `count` measure and a dataset `count` with no field compile to
// `sql: '*'`. The in-memory count branch treated `'*'` as a column name and
// counted non-null of a non-existent property → 0 for every bucket. The
// driver's `COUNT(*)` masked it; the tz≠UTC date-bucket path (which always
// runs in-memory) surfaced it as a 0-count bucket with a correct label.
const evts = [
{ closed_at: '2024-03-01T03:00:00.000Z' }, // NY: 02-29
{ closed_at: '2024-03-01T07:00:00.000Z' }, // NY: 03-01
{ closed_at: '2024-03-01T09:00:00.000Z' }, // NY: 03-01
];

it("counts all rows for a fieldless / `'*'` count, ungrouped", () => {
const out = applyInMemoryAggregation(evts, {
aggregations: [{ function: 'count', field: '*', alias: 'n' }],
});
expect(out).toEqual([{ n: 3 }]);
});

it("counts rows per tz bucket for a `'*'` count (was 0 before the fix)", () => {
const ast = {
groupBy: [{ field: 'closed_at', dateGranularity: 'day' as const }],
aggregations: [{ function: 'count', field: '*', alias: 'n' }],
};
const ny = applyInMemoryAggregation(evts, ast, 'America/New_York').sort(
(a, b) => String(a.closed_at).localeCompare(String(b.closed_at)),
);
expect(ny).toEqual([
{ closed_at: '2024-02-29', n: 1 },
{ closed_at: '2024-03-01', n: 2 },
]);
});
});
8 changes: 7 additions & 1 deletion packages/objectql/src/in-memory-aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ function aggregateBucket(rows: any[], aggregations: AggregationNode[]): Record<s
const alias = agg.alias;
const fn = agg.function;
if (fn === 'count') {
if (!agg.field) {
// `*` is the count-all sentinel: the Cube `count` measure and a dataset
// `count` with no field both compile to `sql: '*'` (→ SQL `COUNT(*)`).
// A row never has a literal `'*'` property, so the non-null branch below
// counted zero for every bucket — the driver's `COUNT(*)` masked it, but
// the in-memory path (tz≠UTC date buckets per #1982, driver-rest/-memory)
// returned 0. Treat `'*'` like a fieldless count over the bucket's rows.
if (!agg.field || agg.field === '*') {
out[alias] = rows.length;
} else {
out[alias] = rows.reduce(
Expand Down
12 changes: 11 additions & 1 deletion packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
} catch { return undefined; }
};

// Settings service resolver — used by resolveExecCtx to resolve the
// reference timezone/locale (localization manifest) through the 4-tier
// cascade incl. the `OS_LOCALIZATION_TIMEZONE` env override. Returns
// undefined when no settings service is registered (UTC default).
const settingsServiceProvider = async (_environmentId?: string): Promise<any | undefined> => {
try {
return ctx.getService<any>('settings');
} catch { return undefined; }
};

if (!server) {
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
return;
Expand All @@ -176,7 +186,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
ctx.logger.info('Hydrating REST API from Protocol...');

try {
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider);
const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider);
restServer.registerRoutes();

ctx.logger.info('REST API successfully registered');
Expand Down
30 changes: 30 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ export class RestServer {
private sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;

constructor(
server: IHttpServer,
Expand All @@ -558,6 +559,7 @@ export class RestServer {
sharingRulesServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
) {
this.protocol = protocol;
this.config = this.normalizeConfig(config);
Expand All @@ -574,6 +576,7 @@ export class RestServer {
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
this.i18nServiceProvider = i18nServiceProvider;
this.analyticsServiceProvider = analyticsServiceProvider;
this.settingsServiceProvider = settingsServiceProvider;
}

/**
Expand Down Expand Up @@ -1021,6 +1024,31 @@ export class RestServer {
}
} catch { /* fall back to self-only */ }
}
// Reference timezone + locale for date rendering and analytics date
// bucketing (ADR-0053 Phase 2 / localization manifest #2006). Resolved
// through the `settings` service so the 4-tier cascade — including the
// env override `OS_LOCALIZATION_TIMEZONE` — is honoured; `sys_setting`
// rows alone would miss the env/global tiers. Best-effort: any failure
// leaves the engine's UTC default. Mirrors the dispatcher path's
// `resolveLocalization` (runtime/security/resolve-execution-context.ts).
let timezone: string | undefined;
let locale: string | undefined;
try {
const settings = this.settingsServiceProvider
? await this.settingsServiceProvider(environmentId).catch(() => undefined)
: undefined;
if (settings && typeof settings.get === 'function') {
const sctx = { tenantId, userId };
const [tzRes, localeRes] = await Promise.all([
settings.get('localization', 'timezone', sctx).catch(() => undefined),
settings.get('localization', 'locale', sctx).catch(() => undefined),
]);
const tzVal = tzRes?.value;
const localeVal = localeRes?.value;
if (typeof tzVal === 'string' && tzVal.trim()) timezone = tzVal.trim();
if (typeof localeVal === 'string' && localeVal.trim()) locale = localeVal.trim();
}
} catch { /* best-effort — fall back to engine UTC default */ }
return {
userId,
tenantId,
Expand All @@ -1029,6 +1057,8 @@ export class RestServer {
systemPermissions,
isSystem: false,
org_user_ids,
...(timezone ? { timezone } : {}),
...(locale ? { locale } : {}),
} as any;
} catch {
return undefined;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Regression (ADR-0053 Phase 2, #1982): on a SQL driver — where BOTH
// `nativeSql` and `objectqlAggregate` capabilities are advertised (the real
// SQLite/Postgres/MySQL deployment) — a date-bucketed query must route to the
// ObjectQLStrategy so `engine.aggregate` buckets by granularity and honours a
// non-UTC reference timezone (in-memory). NativeSQLStrategy groups by the raw
// column (`GROUP BY <col>`, no `date_trunc`) and ignores `timezone`; because it
// has higher precedence (priority 10 < 20) it silently won every cube/dataset
// query, producing one bucket per row and tz-invariant results. The earlier
// granularity test passed only because it forced `nativeSql: false`, masking
// the real misrouting. This test pins the production capability shape.

import { describe, it, expect } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import { AnalyticsService } from '../analytics-service.js';

const dataset = DatasetSchema.parse({
name: 'pipeline',
label: 'Pipeline',
object: 'opportunity',
dimensions: [
{ name: 'stage', field: 'stage', type: 'string' },
{ name: 'close_date', field: 'close_date', type: 'date' },
],
measures: [{ name: 'opp_count', aggregate: 'count' }],
});

// Mirror a real SQL deployment: native SQL AND objectql aggregate both available.
const PROD_CAPS = { nativeSql: true, objectqlAggregate: true, inMemory: true };

function buildService() {
const rawSqlCalls: Array<{ sql: string }> = [];
const aggregateCalls: Array<{ groupBy?: unknown[]; timezone?: string }> = [];
const svc = new AnalyticsService({
queryCapabilities: () => PROD_CAPS,
executeRawSql: async (_object, sql) => {
rawSqlCalls.push({ sql });
return [{ 'close_date': '2026-06-17T01:44:15.667Z', opp_count: 1 }];
},
executeAggregate: async (_object, options) => {
aggregateCalls.push({ groupBy: options.groupBy as unknown[], timezone: options.timezone });
return [{ close_date: '2026-06', opp_count: 6 }];
},
});
return { svc, rawSqlCalls, aggregateCalls };
}

describe('NativeSQLStrategy declines date-granularity queries (regression #1982)', () => {
it('routes a granularity timeDimension to engine.aggregate, NOT raw SQL', async () => {
const { svc, rawSqlCalls, aggregateCalls } = buildService();

await svc.queryDataset!(dataset, {
dimensions: ['close_date'],
measures: ['opp_count'],
timeDimensions: [{ dimension: 'close_date', granularity: 'month' }],
timezone: 'America/New_York',
});

// ObjectQLStrategy handled it (native SQL would have lost the bucket + tz).
expect(rawSqlCalls).toHaveLength(0);
expect(aggregateCalls).toHaveLength(1);
// Bucketed groupBy + reference tz reach the engine.
expect(aggregateCalls[0].groupBy).toEqual([{ field: 'close_date', dateGranularity: 'month' }]);
expect(aggregateCalls[0].timezone).toBe('America/New_York');
});

it('still uses native SQL for a plain (non-bucketed) query', async () => {
const { svc, rawSqlCalls, aggregateCalls } = buildService();

await svc.queryDataset!(dataset, {
dimensions: ['stage'],
measures: ['opp_count'],
});

// No granularity → native SQL is correct and preferred; we did not over-decline.
expect(rawSqlCalls).toHaveLength(1);
expect(aggregateCalls).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export class NativeSQLStrategy implements AnalyticsStrategy {

canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {
if (!query.cube) return false;
// This strategy groups by the raw column expression (`GROUP BY <col>`) and
// emits no `date_trunc` — it cannot bucket a date dimension to a coarser
// granularity, nor resolve buckets on a non-UTC calendar. When the query
// asks for granularity bucketing we therefore DECLINE so the lower-priority
// ObjectQLStrategy handles it via `engine.aggregate` (native date_trunc when
// UTC-safe, else uniform in-memory bucketing). Without this, a date-bucketed
// query silently grouped by the raw timestamp — one bucket per row — and a
// non-UTC reference timezone was ignored entirely (ADR-0053 Phase 2, #1982).
if (query.timeDimensions?.some((td) => !!td.granularity)) return false;
const caps = ctx.queryCapabilities(query.cube);
return caps.nativeSql && typeof ctx.executeRawSql === 'function';
}
Expand Down
Loading