Skip to content

Commit 4d4ae51

Browse files
committed
fix(driver-memory): analytics honours the declared AnalyticsQuery.timezone when resolving a string dateRange (#16042)
`parseDateRangeString()` accepted the field and never read it, so a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was answered on the UTC day — accepted, unwarned, and silently ignoring the field they set. The schema declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain into `query.timezone` before a driver sees it. Two halves, each silent on its own and each pinned: the zone decides WHICH calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern) and WHERE that day begins as an instant (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Half one alone anchors to the zone's calendar day and then cuts it at UTC midnight: a window that is neither day. The end bound is a calendar step, never `+ 86_400_000` — on `America/New_York` 2026-03-08 is 23 hours long. A query carrying no timezone is unchanged by construction, and #15825's two pins stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 658262e commit 4d4ae51

3 files changed

Lines changed: 423 additions & 16 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
`driver-memory` analytics honours `AnalyticsQuery.timezone` when it resolves a string `dateRange`, instead of accepting the field and answering on UTC (#16042)
6+
7+
`AnalyticsQuerySchema` declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain and writes the answer into `query.timezone` before a driver ever sees it. `parseDateRangeString()` never read it: a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was accepted, warned about nothing, and answered on the UTC day.
8+
9+
⚠️ **This changes which rows a query answers for a caller already passing `timezone`.** Measured at `2026-09-06T20:00:00Z` with `timezone: 'Asia/Shanghai'`, `'today'`:
10+
11+
| | window | rows selected, from the same 8 probes |
12+
|:--|:--|:--|
13+
| before | `[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)` — the UTC day | `06T00:00:00.000Z`, `06T15:59:59.999Z`, `06T16:00:00.000Z`, `06T23:59:59.999Z` |
14+
| after | `[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)` — Shanghai's day | `06T16:00:00.000Z`, `06T23:59:59.999Z`, `07T04:00:00.000Z`, `07T15:59:59.999Z` |
15+
16+
Four rows either way, and **two of the four are different rows**: `2026-09-06T00:00:00.000Z` and `2026-09-06T15:59:59.999Z` leave the answer (they are yesterday in Shanghai), `2026-09-07T04:00:00.000Z` and `2026-09-07T15:59:59.999Z` join it (they are today in Shanghai). Both row sets are asserted against the real `MemoryAnalyticsService.query()` entry, in the same test, so the before is measured rather than recalled.
17+
18+
A query carrying **no** timezone is byte-identical to before: `zonedDateStartToUtcMs` returns plain UTC midnight for an unset, `'UTC'`, or unknown zone, so #15825's repair — the common case — is untouched, and both of its pins stay green.
19+
20+
Two halves, each of which fails silently on its own and each of which is pinned: the reference timezone decides **which** calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern), and **where that day begins as an instant** (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Resolving only the first would anchor to the zone's calendar day and then cut it at UTC midnight — a window that is neither the UTC day nor the zone's. The end bound is a calendar step, never `+ 86_400_000`: on `America/New_York`, 2026-03-08 is 23 hours long.
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16042 — `parseDateRangeString()` accepted `AnalyticsQuery.timezone` and
5+
* never read it, so `dateRange: 'today'` answered on the UTC calendar for a
6+
* caller who had said which calendar they meant.
7+
*
8+
* ## What this file pins
9+
*
10+
* ⭐ The one claim the card is about: **a supplied non-UTC `timezone` changes
11+
* which rows `'today'` selects** — both halves of that, because they fail
12+
* independently and each failure is silent:
13+
*
14+
* 1. WHICH calendar day the window is anchored to. `Asia/Shanghai` is
15+
* already on 2026-09-07 while UTC is still on 2026-09-06.
16+
* 2. WHERE that day BEGINS as an instant. `Asia/Kolkata` is on the SAME
17+
* calendar day as UTC at its cell's instant, and its window still starts
18+
* 5h30 earlier — so that cell goes red against a repair that resolves the
19+
* day in the zone and then cuts it at UTC midnight, which is the shape a
20+
* `proxyDay()`-only reading of the card produces.
21+
*
22+
* ## Why the assertions are about ROWS, and what the "before" is
23+
*
24+
* The window is internal; what a caller sees is which rows the answer counted.
25+
* So every cell drives the real public entry, `MemoryAnalyticsService.query()`,
26+
* and asserts the row set — and it asserts the **before** in the same test:
27+
* the identical query with no `timezone` must still return the UTC row set
28+
* (#15825's case, which this must not disturb), and the two row sets must
29+
* DIFFER. That difference is the defect, stated as data.
30+
*
31+
* ## The inline controls are load-bearing
32+
*
33+
* A cell whose zone offset happened to be 0 at its instant would assert
34+
* nothing while passing, so each cell first asserts its two windows disagree,
35+
* and the fences at the bottom assert that every cell is live, that both
36+
* directions are represented (a zone a day AHEAD of UTC and one a day BEHIND),
37+
* that at least one cell shares UTC's calendar day (half 2 alone), and that at
38+
* least one window is 23 hours long (the spring-forward day, which is why the
39+
* end bound is a calendar step and never `+ 86_400_000`).
40+
*
41+
* ⚠️ Every window literal below was computed independently of the code under
42+
* test, from `Intl.DateTimeFormat` alone, by scanning for the instant at which
43+
* the zone's local clock reads `00:00` — never from `@objectstack/core`'s
44+
* primitives, which are what the repair uses. They are data, not a second
45+
* implementation.
46+
*
47+
* ⛔ No probe is placed exactly ON a window's end instant, and a guard below
48+
* asserts that. The call site's upper bound is INCLUSIVE for a full-timestamp
49+
* end (`nextUtcCalendarDay` widens only a bare `YYYY-MM-DD`, so it returns
50+
* `null` here and the bound falls back to `$lte`), which is a separate,
51+
* pre-existing question this card does not touch.
52+
*/
53+
54+
import { describe, it, expect, vi } from 'vitest';
55+
import { InMemoryDriver } from './memory-driver.js';
56+
import { MemoryAnalyticsService } from './memory-analytics.js';
57+
import { AnalyticsQuerySchema } from '@objectstack/spec/data';
58+
import type { AnalyticsQuery, Cube } from '@objectstack/spec/data';
59+
60+
const REAL_TZ = process.env.TZ;
61+
62+
/** Run `fn` with the PROCESS on `zone` and the clock frozen at `instant`. */
63+
async function at<T>(zone: string, instant: string, fn: () => Promise<T>): Promise<T> {
64+
process.env.TZ = zone;
65+
vi.useFakeTimers({ toFake: ['Date'] });
66+
vi.setSystemTime(new Date(instant));
67+
try {
68+
return await fn();
69+
} finally {
70+
vi.useRealTimers();
71+
if (REAL_TZ === undefined) delete process.env.TZ;
72+
else process.env.TZ = REAL_TZ;
73+
}
74+
}
75+
76+
const CUBE: Cube = {
77+
name: 'events',
78+
title: 'Events',
79+
sql: 'events',
80+
measures: {
81+
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
82+
},
83+
dimensions: {
84+
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
85+
createdAt: {
86+
name: 'created_at',
87+
label: 'Created At',
88+
type: 'time',
89+
sql: 'created_at',
90+
granularities: ['day'],
91+
},
92+
},
93+
public: true,
94+
};
95+
96+
const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input);
97+
98+
/** Ask `range` over rows planted at `instants`; answer which probes came back. */
99+
async function probesSelected(
100+
instants: string[],
101+
opts: { range?: string; timezone?: string } = {},
102+
): Promise<string[]> {
103+
const driver = new InMemoryDriver({
104+
initialData: {
105+
events: instants.map((iso, i) => ({
106+
id: i + 1,
107+
probe: iso,
108+
created_at: new Date(iso),
109+
})),
110+
},
111+
});
112+
await driver.connect();
113+
const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] });
114+
const query: AnalyticsQuery = {
115+
cube: 'events',
116+
measures: ['events.count'],
117+
dimensions: ['events.probe'],
118+
timeDimensions: [{ dimension: 'events.createdAt', dateRange: opts.range ?? 'today' }],
119+
};
120+
if (opts.timezone !== undefined) query.timezone = opts.timezone;
121+
const result = await service.query(asQuery(query));
122+
return result.rows.map((row) => String(row['events.probe'])).sort();
123+
}
124+
125+
interface Cell {
126+
zone: string;
127+
/** Frozen clock, always written in UTC. */
128+
instant: string;
129+
/** That zone's local calendar day and clock at `instant`, for readability. */
130+
local: string;
131+
/** `'today'` on the UTC calendar — half-open `[start, end)`. */
132+
utcWindow: [string, string];
133+
/** `'today'` on `zone`'s calendar — half-open `[start, end)`. */
134+
tzWindow: [string, string];
135+
}
136+
137+
/**
138+
* ⭐ Both signs of offset, two zones whose offset is not a whole number of
139+
* hours (Kolkata +05:30, Chatham +12:45), the two extremes of the tz database
140+
* (Kiritimati +14:00, Niue −11:00), a cell that shares UTC's calendar day
141+
* (Kolkata), and one spring-forward day whose window is 23 hours long
142+
* (New York, 2026-03-08).
143+
*/
144+
const CELLS: Cell[] = [
145+
{
146+
zone: 'Asia/Shanghai', instant: '2026-09-06T20:00:00Z', local: '2026-09-07 04:00',
147+
utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'],
148+
tzWindow: ['2026-09-06T16:00:00.000Z', '2026-09-07T16:00:00.000Z'],
149+
},
150+
{
151+
zone: 'America/Los_Angeles', instant: '2026-09-06T04:00:00Z', local: '2026-09-05 21:00',
152+
utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'],
153+
tzWindow: ['2026-09-05T07:00:00.000Z', '2026-09-06T07:00:00.000Z'],
154+
},
155+
{
156+
zone: 'Asia/Kolkata', instant: '2026-09-06T12:00:00Z', local: '2026-09-06 17:30',
157+
utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'],
158+
tzWindow: ['2026-09-05T18:30:00.000Z', '2026-09-06T18:30:00.000Z'],
159+
},
160+
{
161+
zone: 'America/New_York', instant: '2026-03-08T12:00:00Z', local: '2026-03-08 08:00',
162+
utcWindow: ['2026-03-08T00:00:00.000Z', '2026-03-09T00:00:00.000Z'],
163+
tzWindow: ['2026-03-08T05:00:00.000Z', '2026-03-09T04:00:00.000Z'],
164+
},
165+
{
166+
zone: 'Pacific/Kiritimati', instant: '2026-09-06T12:00:00Z', local: '2026-09-07 02:00',
167+
utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'],
168+
tzWindow: ['2026-09-06T10:00:00.000Z', '2026-09-07T10:00:00.000Z'],
169+
},
170+
{
171+
zone: 'Pacific/Niue', instant: '2026-09-06T05:00:00Z', local: '2026-09-05 18:00',
172+
utcWindow: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'],
173+
tzWindow: ['2026-09-05T11:00:00.000Z', '2026-09-06T11:00:00.000Z'],
174+
},
175+
{
176+
zone: 'Pacific/Chatham', instant: '2026-06-15T12:00:00Z', local: '2026-06-16 00:45',
177+
utcWindow: ['2026-06-15T00:00:00.000Z', '2026-06-16T00:00:00.000Z'],
178+
tzWindow: ['2026-06-15T11:15:00.000Z', '2026-06-16T11:15:00.000Z'],
179+
},
180+
];
181+
182+
const label = (c: Cell) => `${c.zone} @ ${c.instant} (local ${c.local})`;
183+
const ms = (iso: string) => Date.parse(iso);
184+
const iso = (t: number) => new Date(t).toISOString();
185+
const inWindow = (t: number, w: [string, string]) => t >= ms(w[0]) && t < ms(w[1]);
186+
187+
/**
188+
* Probes that discriminate the two windows in BOTH directions — rows the zone
189+
* day contains and the UTC day does not, and the reverse. The `+ 30min` probe
190+
* sits just past the zone day's end: it is outside the correct window in every
191+
* cell, and inside a window whose end was computed as `start + 86_400_000` on
192+
* the 23-hour spring-forward day.
193+
*/
194+
function probesFor(c: Cell): string[] {
195+
const [tzStart, tzEnd] = c.tzWindow.map(ms);
196+
const [utcStart, utcEnd] = c.utcWindow.map(ms);
197+
const raw = [
198+
tzStart,
199+
tzStart - 1,
200+
tzStart + Math.floor((tzEnd - tzStart) / 2),
201+
tzEnd - 1,
202+
tzEnd + 30 * 60_000,
203+
utcStart,
204+
utcStart - 1,
205+
utcEnd - 1,
206+
];
207+
return [...new Set(raw)].map(iso);
208+
}
209+
210+
describe("#16042 — a supplied `timezone` decides which calendar day 'today' is", () => {
211+
for (const c of CELLS) {
212+
it(`${label(c)}: 'today' is answered on the zone's calendar, not UTC's`, async () => {
213+
const probes = probesFor(c);
214+
215+
// CONTROL FIRST — a cell whose two windows coincide would pass
216+
// while asserting nothing.
217+
expect(
218+
c.tzWindow[0],
219+
`${label(c)}: the zone window must differ from the UTC window, otherwise this cell pins nothing`,
220+
).not.toBe(c.utcWindow[0]);
221+
222+
// ⛔ Guard: no probe may sit exactly on a window END. The call
223+
// site's upper bound is inclusive for a full-timestamp end, which
224+
// is a separate question — a probe there would pin THAT instead.
225+
for (const p of probes) {
226+
expect(p, `${label(c)}: probe sits on a window end`).not.toBe(c.tzWindow[1]);
227+
expect(p, `${label(c)}: probe sits on a window end`).not.toBe(c.utcWindow[1]);
228+
}
229+
230+
const expectedTz = probes.filter((p) => inWindow(ms(p), c.tzWindow)).sort();
231+
const expectedUtc = probes.filter((p) => inWindow(ms(p), c.utcWindow)).sort();
232+
233+
// The defect, stated as data: the two answers are different rows.
234+
expect(
235+
expectedTz,
236+
`${label(c)}: the zone's row set must differ from UTC's, otherwise this cell pins nothing`,
237+
).not.toEqual(expectedUtc);
238+
239+
await at(c.zone, c.instant, async () => {
240+
// AFTER — the zone is honoured.
241+
await expect(probesSelected(probes, { timezone: c.zone })).resolves.toEqual(expectedTz);
242+
// BEFORE — the same query with no timezone still answers on
243+
// UTC, the chain's terminal fallback (#15825's case).
244+
await expect(probesSelected(probes)).resolves.toEqual(expectedUtc);
245+
// …and the terminal fallback spelled out explicitly.
246+
await expect(probesSelected(probes, { timezone: 'UTC' })).resolves.toEqual(expectedUtc);
247+
});
248+
});
249+
}
250+
251+
it('every cell is live — its zone window differs from the UTC window', () => {
252+
const dead = CELLS.filter((c) => c.tzWindow[0] === c.utcWindow[0]).map(label);
253+
expect(dead, 'a cell that no longer shifts has stopped guarding the fix').toEqual([]);
254+
});
255+
256+
it('both directions are represented — a zone a day AHEAD of UTC and one BEHIND', () => {
257+
const dirs = new Set(
258+
CELLS.map((c) => {
259+
const tzDay = c.local.slice(0, 10);
260+
const utcDay = c.instant.slice(0, 10);
261+
return tzDay === utcDay ? 'same' : tzDay > utcDay ? 'ahead' : 'behind';
262+
}),
263+
);
264+
expect([...dirs].sort()).toEqual(['ahead', 'behind', 'same']);
265+
});
266+
267+
it("at least one cell shares UTC's calendar day — half 2 (the day's START) alone", () => {
268+
// This is the cell a `proxyDay()`-only repair fails: same calendar day,
269+
// different starting instant.
270+
const sameDay = CELLS.filter((c) => c.local.slice(0, 10) === c.instant.slice(0, 10));
271+
expect(sameDay.map(label).length).toBeGreaterThan(0);
272+
for (const c of sameDay) expect(c.tzWindow[0]).not.toBe(c.utcWindow[0]);
273+
});
274+
275+
it('at least one window is 23 hours long — the spring-forward day', () => {
276+
const spans = CELLS.map((c) => (ms(c.tzWindow[1]) - ms(c.tzWindow[0])) / 3_600_000);
277+
expect(spans, 'no cell exercises a DST-shortened day, so `+ 86_400_000` would pass').toContain(23);
278+
});
279+
});
280+
281+
describe('#16042 — the resolution is host-independent and degrades to UTC', () => {
282+
const c = CELLS[0]; // Asia/Shanghai
283+
284+
it('the answer does not depend on the PROCESS timezone', async () => {
285+
const probes = probesFor(c);
286+
const expectedTz = probes.filter((p) => inWindow(ms(p), c.tzWindow)).sort();
287+
for (const hostZone of ['UTC', 'America/Los_Angeles', 'Asia/Tokyo', 'Pacific/Chatham']) {
288+
await at(hostZone, c.instant, async () => {
289+
await expect(
290+
probesSelected(probes, { timezone: c.zone }),
291+
`host TZ=${hostZone} changed the answer`,
292+
).resolves.toEqual(expectedTz);
293+
});
294+
}
295+
});
296+
297+
it('an unknown zone degrades to UTC rather than throwing', async () => {
298+
const probes = probesFor(c);
299+
const expectedUtc = probes.filter((p) => inWindow(ms(p), c.utcWindow)).sort();
300+
await at('Asia/Tokyo', c.instant, async () => {
301+
await expect(probesSelected(probes, { timezone: 'Mars/Olympus' })).resolves.toEqual(expectedUtc);
302+
});
303+
});
304+
});
305+
306+
describe("#16042 — `last N …` anchors on the zone's calendar too", () => {
307+
const c = CELLS[0]; // Asia/Shanghai: local day 2026-09-07, UTC day 2026-09-06
308+
309+
it("'last 7 days' starts 7 days before the ZONE's day, at the zone's midnight", async () => {
310+
// 7 days before Shanghai's 2026-09-07 is 2026-08-31; that day begins at
311+
// 2026-08-30T16:00:00.000Z. Computed independently: Shanghai is +08:00
312+
// year-round, so its midnight is the previous day's 16:00Z.
313+
const tzStart = '2026-08-30T16:00:00.000Z';
314+
const utcStart = '2026-08-30T00:00:00.000Z'; // 7 days before UTC's 2026-09-06
315+
expect(tzStart).not.toBe(utcStart);
316+
317+
// The upper bound of a `last N` window is the current INSTANT, so a
318+
// probe must sit before it; both probes do.
319+
const probes = [utcStart, tzStart, iso(ms(tzStart) - 1)];
320+
321+
await at('Asia/Tokyo', c.instant, async () => {
322+
// AFTER — the window opens at Shanghai's midnight, so the probe one
323+
// millisecond earlier is OUT.
324+
await expect(probesSelected(probes, { range: 'last 7 days', timezone: c.zone })).resolves.toEqual(
325+
[tzStart].sort(),
326+
);
327+
// BEFORE — with no timezone the window opens 16 hours earlier, at
328+
// UTC midnight, and takes all three probes. That extra row IS the
329+
// defect, in the `last N` leg.
330+
await expect(probesSelected(probes, { range: 'last 7 days' })).resolves.toEqual(
331+
[...probes].sort(),
332+
);
333+
});
334+
});
335+
});

0 commit comments

Comments
 (0)