Skip to content

Commit cb1cb6e

Browse files
committed
fix(driver-memory): analytics dateRange resolves on the UTC calendar, not the process-local one (#15825)
`parseDateRangeString()` built its window on the LOCAL calendar and rendered it as UTC. Two independent defects, both repaired here: 1. The boundary was `new Date(y, m, d)` — LOCAL midnight — rendered by `toISOString()` as UTC. Wrong on every day of the year in every non-UTC process, no DST transition needed: measured, the `'today'` bucket ran from the previous 16:00Z at `Asia/Shanghai` and from 07:00Z at `America/Los_Angeles`. 2. The `last N ...` legs did day/week/month/year arithmetic on the LOCAL calendar and rendered it on the UTC one, so the window start slipped an hour across a DST transition — and a whole day on the `month` leg. Neither repair fixes the other, so each is pinned by its own file and each was ablated separately. The DST file cannot go red at `TZ=UTC`, where the two spellings are indistinguishable — which is why CI never reddened on this. UTC is the target calendar because the rest of the platform already resolves a bare date to the UTC day (`{today}`, `{TODAY()}`), and disagreeing with it let one deployment answer the same question two ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent 2024eca commit cb1cb6e

4 files changed

Lines changed: 698 additions & 5 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
`driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825)
6+
7+
`MemoryAnalyticsService.query()` lowers a string `dateRange` through
8+
`parseDateRangeString()`, and that function built its window on the **local**
9+
calendar and rendered it as **UTC**. Two independent defects lived in it.
10+
11+
**1. The window boundary was local midnight.** `new Date(y, m, d)` constructs
12+
local midnight; `toISOString()` renders that instant in UTC. So in any process
13+
not sitting at UTC, the `'today'` bucket was the **local** day expressed as a
14+
UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`:
15+
16+
| `TZ` | `'today'` window produced | the UTC day it should be |
17+
|:---|:---|:---|
18+
| `UTC` | `2026-09-05T00:00Z``2026-09-06T00:00Z` | (agrees) |
19+
| `Asia/Shanghai` | `2026-09-05T16:00Z``2026-09-06T16:00Z` | `2026-09-05T00:00Z``2026-09-06T00:00Z` |
20+
| `America/Los_Angeles` | `2026-09-05T07:00Z``2026-09-06T07:00Z` | `2026-09-05T00:00Z``2026-09-06T00:00Z` |
21+
| `Europe/Berlin` | `2026-09-04T22:00Z``2026-09-05T22:00Z` | `2026-09-05T00:00Z``2026-09-06T00:00Z` |
22+
| `Asia/Kolkata` | `2026-09-05T18:30Z``2026-09-06T18:30Z` | `2026-09-05T00:00Z``2026-09-06T00:00Z` |
23+
24+
That is wrong on **every day of the year**, with no DST transition needed.
25+
26+
**2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is
27+
local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves
28+
wall-clock time, so the instant moves `n × 24h` only while every local day in
29+
the window is 24 hours long; across a DST transition it moves 23h or 25h and
30+
the window start slips an hour. `setMonth` / `setFullYear` are the same class,
31+
and can move it by a whole day: at `America/New_York` with the clock at
32+
`2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of
33+
`2025-12-01T00:00Z`.
34+
35+
**⛔ The two do not fix each other**, which is the easiest thing to get wrong
36+
here: `setUTCDate` alone leaves the local-midnight boundary in place, and
37+
`Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned
38+
by its own file, and each was ablated on its own to prove the separation.
39+
40+
**Why UTC and not "any consistent calendar".** The rest of the platform
41+
resolves a bare date to the UTC day — `@objectstack/core`'s `{today}`
42+
filter-token macro builds its reference day as
43+
`new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the
44+
context carries no timezone, and `{TODAY()}` in flow templates resolves to the
45+
UTC day (#14852 repaired the identical two-calendar shape there). Before this
46+
change the same analytics question asked through the driver's `dateRange` and
47+
through a flow token could select **different rows in one deployment**. UTC is
48+
also the terminal fallback of the engine's own resolution chain
49+
(`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2).
50+
51+
**What did not change.** The parser's vocabulary, its `[range, range]`
52+
fallback, and the shape of the emitted `$match` are untouched — this is a
53+
calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted
54+
by this path; making the range tokens timezone-**aware** is a separate and
55+
larger question, which #14852 also declined.
56+
57+
**Who sees a difference.** Any deployment whose process is not at UTC: `'today'`
58+
and `last N ...` now select the UTC day they always claimed to, so charts built
59+
on a string `dateRange` shift by the process offset — toward agreement with
60+
`{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments
61+
already running at UTC are unaffected; the two spellings are indistinguishable
62+
there, which is exactly why CI never reddened on this.
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #15825 — DEFECT 2 of 2: the `last N ...` legs of `parseDateRangeString()`
5+
* did their arithmetic on the LOCAL calendar and rendered it on the UTC one.
6+
*
7+
* ## ⛔ Why this file cannot be written to run only at `TZ=UTC`
8+
*
9+
* The two spellings — local `setDate(getDate() - n)` and UTC
10+
* `setUTCDate(getUTCDate() - n)` — are behaviourally INDISTINGUISHABLE at
11+
* `TZ=UTC`, which is precisely why nothing in CI ever went red on this and why
12+
* it shipped. Every case below therefore fakes BOTH halves of the environment:
13+
* a DST-observing zone (`process.env.TZ`, re-read by V8 on the next `Date`
14+
* operation) AND an instant whose day/month/year shift crosses that zone's own
15+
* transition.
16+
*
17+
* ## The mechanism, stated once
18+
*
19+
* `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n
20+
* days moves the INSTANT by exactly n x 24h only while every local day in the
21+
* window is 24 hours long. Across a spring-forward that window is 23 hours;
22+
* across a fall-back, 25. The rendering is `toISOString()` — UTC. So the
23+
* window start slips an hour, and rows in that hour are wrongly gained or
24+
* wrongly lost. `setMonth` / `setFullYear` are the same class, and the
25+
* `last 1 month` cell below shows the local calendar can move the answer by a
26+
* whole DAY, not merely an hour.
27+
*
28+
* ## ⭐ This is a SEPARATE defect from the window boundary
29+
*
30+
* Defect 1 — `new Date(y, m, d)` building LOCAL midnight — is pinned in
31+
* `memory-analytics-date-range-utc-window.test.ts`. ⛔ Neither repair fixes the
32+
* other, and each was ablated on its own to prove it. Every cell here is built
33+
* on a boundary that is ALREADY `Date.UTC`, so what it measures is the
34+
* arithmetic leg alone.
35+
*
36+
* ## The oracle is timezone-INVARIANCE, not a second implementation
37+
*
38+
* For `month` and `year` there is no offset-free definition of the answer to
39+
* compare against, so re-deriving one would just be the fix written twice. The
40+
* oracle used instead is the property the card is actually about: at `TZ=UTC`
41+
* the two spellings coincide, so the `TZ=UTC` run IS the reference answer, and
42+
* a correct implementation must return exactly that answer in every other
43+
* zone. Where a definition does exist (`day` / `week`, since a UTC day is
44+
* always 24h) it is asserted as well.
45+
*
46+
* ## The inline control is load-bearing
47+
*
48+
* Each cell also evaluates the OLD spelling directly and asserts it DISAGREES
49+
* with the one-calendar answer. Without it a green run would be ambiguous
50+
* between "the fix works" and "these instants are not actually in a transition
51+
* window" — the second being the failure mode that hid this bug for so long.
52+
*/
53+
54+
import { describe, it, expect } from 'vitest';
55+
import { vi } from 'vitest';
56+
import { InMemoryDriver } from './memory-driver.js';
57+
import { MemoryAnalyticsService } from './memory-analytics.js';
58+
import { AnalyticsQuerySchema } from '@objectstack/spec/data';
59+
import type { AnalyticsQuery, Cube } from '@objectstack/spec/data';
60+
61+
const REAL_TZ = process.env.TZ;
62+
63+
/** Run `fn` with the process on `zone` and the clock frozen at `instant`. */
64+
async function at<T>(zone: string, instant: string, fn: () => Promise<T>): Promise<T> {
65+
process.env.TZ = zone;
66+
vi.useFakeTimers({ toFake: ['Date'] });
67+
vi.setSystemTime(new Date(instant));
68+
try {
69+
return await fn();
70+
} finally {
71+
vi.useRealTimers();
72+
if (REAL_TZ === undefined) delete process.env.TZ;
73+
else process.env.TZ = REAL_TZ;
74+
}
75+
}
76+
77+
type Unit = 'day' | 'week' | 'month' | 'year';
78+
79+
/** The window boundary, already repaired — defect 1 is not what this file measures. */
80+
function utcBoundary(): Date {
81+
const n = new Date();
82+
return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate()));
83+
}
84+
85+
/** The DEFECT, spelled out: UTC boundary, arithmetic on the LOCAL calendar. */
86+
function localArithmeticStart(unit: Unit, num: number): string {
87+
const s = utcBoundary();
88+
if (unit === 'day') s.setDate(s.getDate() - num);
89+
else if (unit === 'week') s.setDate(s.getDate() - num * 7);
90+
else if (unit === 'month') s.setMonth(s.getMonth() - num);
91+
else s.setFullYear(s.getFullYear() - num);
92+
return s.toISOString();
93+
}
94+
95+
/**
96+
* ⚠️ Used ONLY to place probe rows on either side of the two candidate
97+
* boundaries — never as the oracle. The oracle is the `TZ=UTC` run below.
98+
*/
99+
function utcArithmeticStart(unit: Unit, num: number): string {
100+
const s = utcBoundary();
101+
if (unit === 'day') s.setUTCDate(s.getUTCDate() - num);
102+
else if (unit === 'week') s.setUTCDate(s.getUTCDate() - num * 7);
103+
else if (unit === 'month') s.setUTCMonth(s.getUTCMonth() - num);
104+
else s.setUTCFullYear(s.getUTCFullYear() - num);
105+
return s.toISOString();
106+
}
107+
108+
const CUBE: Cube = {
109+
name: 'events',
110+
title: 'Events',
111+
sql: 'events',
112+
measures: {
113+
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
114+
},
115+
dimensions: {
116+
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
117+
createdAt: {
118+
name: 'created_at',
119+
label: 'Created At',
120+
type: 'time',
121+
sql: 'created_at',
122+
granularities: ['day'],
123+
},
124+
},
125+
public: true,
126+
};
127+
128+
const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input);
129+
130+
/** Ask `range` over rows planted at `instants`; answer which probes came back. */
131+
async function probesSelected(instants: string[], range: string): Promise<string[]> {
132+
const driver = new InMemoryDriver({
133+
initialData: {
134+
events: instants.map((iso, i) => ({
135+
id: i + 1,
136+
probe: iso,
137+
created_at: new Date(iso),
138+
})),
139+
},
140+
});
141+
await driver.connect();
142+
const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] });
143+
const result = await service.query(asQuery({
144+
cube: 'events',
145+
measures: ['events.count'],
146+
dimensions: ['events.probe'],
147+
timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }],
148+
}));
149+
return result.rows.map((row) => String(row['events.probe'])).sort();
150+
}
151+
152+
interface Cell {
153+
zone: string;
154+
/** Frozen clock, always written in UTC. */
155+
instant: string;
156+
range: string;
157+
unit: Unit;
158+
num: number;
159+
kind: 'spring-forward' | 'fall-back';
160+
}
161+
162+
/**
163+
* Red cells — every one MEASURED, ⛔ not guessed: each is an instant at which
164+
* the local-arithmetic spelling actually disagrees with the one-calendar
165+
* answer in that zone, taken from a 9-zone x 366-day sweep of 2026 across all
166+
* four units (2026-09-05). Both hemispheres, both transition directions, all
167+
* four legs (`day` / `week` / `month` / `year`), and two zones whose standard
168+
* offset is not a whole hour (St_Johns -03:30, Chatham +12:45) so a
169+
* whole-hour assumption cannot hide in the repair.
170+
*/
171+
const DST_CELLS: Cell[] = [
172+
{ zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' },
173+
{ zone: 'America/Los_Angeles', instant: '2026-03-09T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' },
174+
{ zone: 'America/St_Johns', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' },
175+
{ zone: 'Europe/London', instant: '2026-03-30T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' },
176+
{ zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 1 week', unit: 'week', num: 1, kind: 'spring-forward' },
177+
{ zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'spring-forward' },
178+
{ zone: 'Australia/Sydney', instant: '2026-04-05T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'fall-back' },
179+
{ zone: 'Pacific/Auckland', instant: '2026-04-05T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'fall-back' },
180+
{ zone: 'Pacific/Chatham', instant: '2026-04-05T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' },
181+
{ zone: 'America/New_York', instant: '2026-01-01T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' },
182+
{ zone: 'Europe/London', instant: '2026-01-01T12:00:00Z', range: 'last 3 months', unit: 'month', num: 3, kind: 'fall-back' },
183+
{ zone: 'America/Santiago', instant: '2026-04-06T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'fall-back' },
184+
{ zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'spring-forward' },
185+
];
186+
187+
const label = (c: Cell) => `${c.zone} @ ${c.instant} '${c.range}'`;
188+
189+
/** Probe instants straddling BOTH candidate boundaries, computed in-zone. */
190+
function probesFor(c: Cell): string[] {
191+
const truth = Date.parse(utcArithmeticStart(c.unit, c.num));
192+
const mixed = Date.parse(localArithmeticStart(c.unit, c.num));
193+
return [...new Set([
194+
new Date(truth).toISOString(),
195+
new Date(truth - 1).toISOString(),
196+
new Date(mixed).toISOString(),
197+
new Date(mixed - 1).toISOString(),
198+
new Date(truth + 43_200_000).toISOString(), // comfortably inside, both ways
199+
])].sort();
200+
}
201+
202+
describe('#15825 defect 2 — the `last N ...` legs resolve on one calendar, across DST transitions', () => {
203+
for (const c of DST_CELLS) {
204+
it(`${c.kind}: ${label(c)}`, async () => {
205+
const { truth, mixed, probes } = await at(c.zone, c.instant, async () => ({
206+
truth: utcArithmeticStart(c.unit, c.num),
207+
mixed: localArithmeticStart(c.unit, c.num),
208+
probes: probesFor(c),
209+
}));
210+
211+
// CONTROL FIRST — if these agree, the cell is not in a transition
212+
// window and every assertion below would be vacuous. (It is the
213+
// whole reason a TZ=UTC-only test is worthless here.)
214+
expect(
215+
mixed,
216+
`${label(c)}: the local-arithmetic spelling must DISAGREE here, otherwise this cell pins nothing`,
217+
).not.toBe(truth);
218+
219+
const inZone = await at(c.zone, c.instant, () => probesSelected(probes, c.range));
220+
const atUtc = await at('UTC', c.instant, () => probesSelected(probes, c.range));
221+
222+
// THE ORACLE: at TZ=UTC the two spellings coincide, so this run is
223+
// the reference answer. The process timezone must not move it.
224+
expect(inZone, `${label(c)}: the process timezone changed which rows were counted`).toEqual(atUtc);
225+
226+
// And the answer must actually be non-trivial — a window that
227+
// selected everything or nothing would compare equal for free.
228+
expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeGreaterThan(0);
229+
expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeLessThan(probes.length);
230+
});
231+
}
232+
233+
it('the day and week legs also match the offset-free definition — n x 24h before the UTC day', async () => {
234+
for (const c of DST_CELLS.filter((x) => x.unit === 'day' || x.unit === 'week')) {
235+
await at(c.zone, c.instant, async () => {
236+
const days = c.unit === 'week' ? c.num * 7 : c.num;
237+
expect(utcArithmeticStart(c.unit, c.num), label(c))
238+
.toBe(new Date(utcBoundary().getTime() - days * 86_400_000).toISOString());
239+
});
240+
}
241+
});
242+
243+
it('every red cell is live — the local-arithmetic spelling disagrees in all of them', async () => {
244+
const live: string[] = [];
245+
for (const c of DST_CELLS) {
246+
await at(c.zone, c.instant, async () => {
247+
if (localArithmeticStart(c.unit, c.num) !== utcArithmeticStart(c.unit, c.num)) live.push(label(c));
248+
});
249+
}
250+
expect(live.length, 'a cell that no longer flips has stopped guarding the fix').toBe(DST_CELLS.length);
251+
});
252+
253+
it('both directions are represented — a window start too EARLY and one too LATE', async () => {
254+
const dirs = new Set<string>();
255+
for (const c of DST_CELLS) {
256+
await at(c.zone, c.instant, async () => {
257+
dirs.add(localArithmeticStart(c.unit, c.num) < utcArithmeticStart(c.unit, c.num) ? 'early' : 'late');
258+
});
259+
}
260+
expect([...dirs].sort()).toEqual(['early', 'late']);
261+
});
262+
263+
it('all four arithmetic legs are covered — day, week, month and year', () => {
264+
expect([...new Set(DST_CELLS.map((c) => c.unit))].sort()).toEqual(['day', 'month', 'week', 'year']);
265+
});
266+
});
267+
268+
// ── Fences: what this change must NOT have moved ──────────────────────────
269+
270+
describe('#15825 defect 2 fences', () => {
271+
it('⛔ at TZ=UTC the two spellings are INDISTINGUISHABLE — a UTC-only test proves nothing', async () => {
272+
for (const c of DST_CELLS) {
273+
await at('UTC', c.instant, async () => {
274+
expect(localArithmeticStart(c.unit, c.num), label(c)).toBe(utcArithmeticStart(c.unit, c.num));
275+
});
276+
}
277+
});
278+
279+
it('zones that do not observe DST are unaffected — both spellings already agreed on the day legs there', async () => {
280+
for (const zone of ['UTC', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Perth']) {
281+
for (const instant of ['2026-03-09T12:00:00Z', '2026-11-02T12:00:00Z', '2026-06-15T12:00:00Z']) {
282+
await at(zone, instant, async () => {
283+
expect(localArithmeticStart('day', 7), `${zone} @ ${instant}`)
284+
.toBe(utcArithmeticStart('day', 7));
285+
});
286+
}
287+
}
288+
});
289+
290+
it('an ordinary instant in a DST zone is unaffected — the local day is 24h there', async () => {
291+
for (const zone of [...new Set(DST_CELLS.map((c) => c.zone))]) {
292+
await at(zone, '2026-06-15T12:00:00Z', async () => {
293+
expect(localArithmeticStart('day', 3), zone).toBe(utcArithmeticStart('day', 3));
294+
});
295+
}
296+
});
297+
298+
it('the process timezone is restored after every case', () => {
299+
expect(process.env.TZ).toBe(REAL_TZ);
300+
});
301+
});

0 commit comments

Comments
 (0)