Skip to content

Commit b94d3e9

Browse files
committed
fix(driver-memory): make a resolved dateRange window's upper bound exclusive
`parseDateRangeString('today')` returns the day's start instant and the NEXT day's start instant, and the analytics call site compared that upper bound with `$lte` — `nextUtcCalendarDay` widens only a bare `YYYY-MM-DD` and returns null for an instant, so the half-open branch was never taken. `'today'` was one day plus one instant long, two adjacent day windows overlapped at midnight, and a row stamped there was counted in both. The resolver now states whether the bound it produced is exclusive, and only a window this driver resolved is compared with `$lt`. An explicit `dateRange: [a, b]` is untouched: a caller-written timestamp end keeps the inclusive reading this package publishes today, and a bare-day end keeps its whole-day widening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
1 parent 97adce2 commit b94d3e9

2 files changed

Lines changed: 421 additions & 12 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16179 — `dateRange: 'today'` counted the first instant of TOMORROW.
5+
*
6+
* ## The defect, in one sentence
7+
*
8+
* `parseDateRangeString('today')` returns the day's start instant and the NEXT
9+
* day's start instant; the call site compared that upper bound with `$lte`
10+
* (`nextUtcCalendarDay` widens only a bare `YYYY-MM-DD` and returns `null` for
11+
* an instant, so the half-open branch was never taken). `'today'` was therefore
12+
* one day PLUS ONE INSTANT long, two adjacent day windows overlapped at
13+
* midnight, and a row stamped exactly there was counted in BOTH — silently, no
14+
* error, no warning.
15+
*
16+
* ## ⭐ The control is the point of this file
17+
*
18+
* The card offered two routes and the second was taken: fix what the RELATIVE
19+
* TOKENS emit, and ⛔ leave an explicit `dateRange: [a, b]` alone — `$lte` on a
20+
* caller-written timestamp end is a PUBLISHED reading (`@objectstack/driver-memory`
21+
* is a released package) and narrowing it is a decision nobody made. So every
22+
* case below asks the SAME rows through BOTH arms of `AnalyticsDateRangeSchema`
23+
* and asserts the difference between the two answers is EXACTLY the boundary
24+
* instant — the token dropped it, the explicit array still keeps it. A repair
25+
* that drifted into route 1 goes red here, on the explicit-array leg, not on
26+
* the token leg.
27+
*
28+
* ## Why the assertions are about ROWS, and why both storage forms
29+
*
30+
* The window is internal; what a caller sees is which rows the answer counted,
31+
* so every case drives the real public entry `MemoryAnalyticsService.query()`
32+
* through `AnalyticsQuerySchema.parse`. And the call site builds TWO bounds
33+
* joined by `$or` — one for `Date`-valued rows, one for ISO-string rows, the
34+
* two forms an in-memory table really holds — so each case runs on both. A
35+
* repair applied to one leg only passes half of this file.
36+
*
37+
* ## What this file deliberately does NOT pin
38+
*
39+
* - The `last N …` leg and the unresolved-preset fallback. The declared
40+
* vocabulary spells its presets `last_7_days` while the parser matches
41+
* `startsWith('last ')`, so every preset but `'today'` currently takes the
42+
* `[range, range]` fallback — that mismatch is #16322 and the fallback's
43+
* match-everything behaviour is #16041. ⛔ Neither is pinned here; pinning
44+
* either would freeze a defect as a contract.
45+
* - Which calendar the window is anchored to (#16042) and where a day BEGINS
46+
* (#15825). Both are pinned by their own files. This file's cells carry a
47+
* non-UTC zone only to prove the repair also holds where the upper bound is a
48+
* ZONE's midnight instant rather than UTC's.
49+
*/
50+
51+
import { describe, it, expect, vi } from 'vitest';
52+
import { InMemoryDriver } from './memory-driver.js';
53+
import { MemoryAnalyticsService } from './memory-analytics.js';
54+
import { AnalyticsQuerySchema } from '@objectstack/spec/data';
55+
import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data';
56+
57+
const REAL_TZ = process.env.TZ;
58+
59+
/** Run `fn` with the PROCESS on `zone` and the clock frozen at `instant`. */
60+
async function at<T>(zone: string, instant: string, fn: () => Promise<T>): Promise<T> {
61+
process.env.TZ = zone;
62+
vi.useFakeTimers({ toFake: ['Date'] });
63+
vi.setSystemTime(new Date(instant));
64+
try {
65+
return await fn();
66+
} finally {
67+
vi.useRealTimers();
68+
if (REAL_TZ === undefined) delete process.env.TZ;
69+
else process.env.TZ = REAL_TZ;
70+
}
71+
}
72+
73+
const CUBE: Cube = {
74+
name: 'events',
75+
title: 'Events',
76+
sql: 'events',
77+
measures: {
78+
count: { name: 'count', label: 'Count', type: 'count', sql: 'id' },
79+
},
80+
dimensions: {
81+
probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' },
82+
createdAt: {
83+
name: 'created_at',
84+
label: 'Created At',
85+
type: 'time',
86+
sql: 'created_at',
87+
granularities: ['day'],
88+
},
89+
},
90+
public: true,
91+
};
92+
93+
const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input);
94+
95+
/**
96+
* The two shapes an in-memory table really holds for a datetime — the `Date`
97+
* a direct JS caller writes and the ISO string the driver's own `created_at`
98+
* default and every REST/JSON write produce. The call site builds one bound
99+
* for each and `$or`s them, so every case runs on both.
100+
*/
101+
const STORAGE = ['Date', 'ISO string'] as const;
102+
type Storage = (typeof STORAGE)[number];
103+
104+
/** Ask `range` over rows planted at `instants`; answer which probes came back. */
105+
async function probesSelected(
106+
instants: string[],
107+
opts: { range: AnalyticsDateRange; timezone?: string; storage: Storage },
108+
): Promise<string[]> {
109+
const driver = new InMemoryDriver({
110+
initialData: {
111+
events: instants.map((iso, i) => ({
112+
id: i + 1,
113+
probe: iso,
114+
created_at: opts.storage === 'Date' ? new Date(iso) : iso,
115+
})),
116+
},
117+
});
118+
await driver.connect();
119+
const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] });
120+
const query: AnalyticsQuery = {
121+
cube: 'events',
122+
measures: ['events.count'],
123+
dimensions: ['events.probe'],
124+
timeDimensions: [{ dimension: 'events.createdAt', dateRange: opts.range }],
125+
};
126+
if (opts.timezone !== undefined) query.timezone = opts.timezone;
127+
const result = await service.query(asQuery(query));
128+
return result.rows.map((row) => String(row['events.probe'])).sort();
129+
}
130+
131+
const ms = (iso: string) => Date.parse(iso);
132+
const iso = (t: number) => new Date(t).toISOString();
133+
134+
/**
135+
* What `zone`'s local clock reads at `instant`, from the platform tz database
136+
* ALONE — ⛔ never from the primitives the repair uses. This is what makes the
137+
* window literals below data rather than a second implementation: each one is
138+
* asserted to be a local midnight by this function.
139+
*/
140+
function localClock(instant: string, zone: string): string {
141+
return new Intl.DateTimeFormat('en-CA', {
142+
timeZone: zone,
143+
hourCycle: 'h23',
144+
year: 'numeric', month: '2-digit', day: '2-digit',
145+
hour: '2-digit', minute: '2-digit', second: '2-digit',
146+
fractionalSecondDigits: 3,
147+
}).format(new Date(instant));
148+
}
149+
150+
interface Cell {
151+
zone: string;
152+
/** Frozen clock, always written in UTC. */
153+
instant: string;
154+
/** `'today'` on `zone`'s calendar — the half-open `[start, end)` it MUST be. */
155+
window: [string, string];
156+
}
157+
158+
/**
159+
* UTC (where the bound is UTC midnight), a zone AHEAD of UTC whose day boundary
160+
* is a plain non-UTC instant, one BEHIND, and one whose offset is not a whole
161+
* number of hours — so a repair that quietly re-derived the bound as a UTC day
162+
* cannot pass. Every `window` literal is checked against `Intl` below.
163+
*/
164+
const CELLS: Cell[] = [
165+
{ zone: 'UTC', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'] },
166+
{ zone: 'Asia/Shanghai', instant: '2026-09-06T20:00:00Z', window: ['2026-09-06T16:00:00.000Z', '2026-09-07T16:00:00.000Z'] },
167+
{ zone: 'America/Denver', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T06:00:00.000Z', '2026-09-07T06:00:00.000Z'] },
168+
{ zone: 'Asia/Kolkata', instant: '2026-09-06T12:00:00Z', window: ['2026-09-05T18:30:00.000Z', '2026-09-06T18:30:00.000Z'] },
169+
];
170+
171+
const label = (c: Cell) => `${c.zone} @ ${c.instant}`;
172+
173+
/**
174+
* Probes around the window's UPPER bound, which is the only place this card
175+
* lives: the last instant inside, the boundary instant itself, the first
176+
* instant after — plus an unambiguous midday anchor and the window's own start,
177+
* so a repair that broke the LOWER bound cannot pass either.
178+
*/
179+
function probesFor(c: Cell): string[] {
180+
const [start, end] = c.window.map(ms);
181+
return [...new Set([
182+
start,
183+
start + Math.floor((end - start) / 2),
184+
end - 1,
185+
end, // ⭐ THE instant this card is about
186+
end + 1,
187+
])].map(iso);
188+
}
189+
190+
/** The rows a half-open `[start, end)` window contains. */
191+
const halfOpen = (probes: string[], w: [string, string]) =>
192+
probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) < ms(w[1])).sort();
193+
194+
/** The rows a closed `[start, end]` window contains — today's explicit-array reading. */
195+
const closed = (probes: string[], w: [string, string]) =>
196+
probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) <= ms(w[1])).sort();
197+
198+
describe("#16179 — 'today' stops BEFORE tomorrow's first instant", () => {
199+
for (const c of CELLS) {
200+
for (const storage of STORAGE) {
201+
it(`${label(c)} · ${storage}: the row at the next day's 00:00:00.000 is NOT counted`, async () => {
202+
const probes = probesFor(c);
203+
const boundary = c.window[1];
204+
205+
// CONTROL FIRST — a probe set that never touches the boundary
206+
// would pass while asserting nothing about this card.
207+
expect(probes, `${label(c)}: the boundary instant must be planted`).toContain(boundary);
208+
209+
const expected = halfOpen(probes, c.window);
210+
expect(expected, 'the boundary instant must be OUTSIDE the expected set').not.toContain(boundary);
211+
212+
await at(c.zone, c.instant, async () => {
213+
await expect(
214+
probesSelected(probes, { range: 'today', timezone: c.zone, storage }),
215+
).resolves.toEqual(expected);
216+
});
217+
});
218+
219+
it(`${label(c)} · ${storage}: ⭐ an explicit [a, b] over the SAME window still counts b`, async () => {
220+
const probes = probesFor(c);
221+
const explicit: AnalyticsDateRange = [c.window[0], c.window[1]];
222+
223+
// The published reading, unchanged: a caller-written timestamp
224+
// end is INCLUSIVE. ⛔ This is route 1's tripwire — a repair
225+
// that made `$lt` unconditional reddens HERE.
226+
const expected = closed(probes, c.window);
227+
expect(expected, 'the control must include the boundary instant').toContain(c.window[1]);
228+
229+
await at(c.zone, c.instant, async () => {
230+
await expect(
231+
probesSelected(probes, { range: explicit, timezone: c.zone, storage }),
232+
).resolves.toEqual(expected);
233+
});
234+
});
235+
236+
it(`${label(c)} · ${storage}: the two arms differ by EXACTLY the boundary instant`, async () => {
237+
const probes = probesFor(c);
238+
const explicit: AnalyticsDateRange = [c.window[0], c.window[1]];
239+
240+
await at(c.zone, c.instant, async () => {
241+
const token = await probesSelected(probes, { range: 'today', timezone: c.zone, storage });
242+
const array = await probesSelected(probes, { range: explicit, timezone: c.zone, storage });
243+
244+
// Stated as data: the repair removed one instant from the
245+
// token's answer and nothing at all from the array's.
246+
expect(array.filter((p) => !token.includes(p))).toEqual([c.window[1]]);
247+
expect(token.filter((p) => !array.includes(p))).toEqual([]);
248+
});
249+
});
250+
}
251+
}
252+
253+
it('every window literal is a local midnight in its own zone — checked against `Intl`, not against the code under test', () => {
254+
for (const c of CELLS) {
255+
for (const bound of c.window) {
256+
expect(
257+
localClock(bound, c.zone),
258+
`${label(c)}: ${bound} is not midnight in ${c.zone}`,
259+
).toMatch(/ 00:00:00\.000$/);
260+
}
261+
}
262+
});
263+
264+
it('every window is exactly one calendar day apart and the cells are not all UTC', () => {
265+
for (const c of CELLS) {
266+
const [start, end] = c.window.map(ms);
267+
expect(end - start, `${label(c)}: window is not 24h`).toBe(86_400_000);
268+
}
269+
const nonUtcBoundaries = CELLS.filter((c) => !c.window[1].endsWith('T00:00:00.000Z'));
270+
expect(
271+
nonUtcBoundaries.length,
272+
'every cell ends at UTC midnight, so a repair that re-derived the bound as a UTC day would pass',
273+
).toBeGreaterThan(0);
274+
});
275+
});
276+
277+
describe('#16179 — two adjacent day windows PARTITION the midnight instant', () => {
278+
// The double-count, driven end to end: the same row, the same table, asked
279+
// on two consecutive days. Before the repair it answered `2` — counted by
280+
// the day it ends and by the day it begins.
281+
const ZONE = 'Asia/Shanghai';
282+
const MIDNIGHT = '2026-09-06T16:00:00.000Z'; // where 2026-09-07 begins in Shanghai
283+
284+
for (const storage of STORAGE) {
285+
it(`${storage}: the row at midnight is counted by exactly one of the two days`, async () => {
286+
const probes = [MIDNIGHT];
287+
let hits = 0;
288+
289+
// The day that ENDS at MIDNIGHT.
290+
await at(ZONE, '2026-09-06T12:00:00Z', async () => {
291+
const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage });
292+
hits += got.length;
293+
});
294+
// The day that BEGINS at MIDNIGHT.
295+
await at(ZONE, '2026-09-07T02:00:00Z', async () => {
296+
const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage });
297+
hits += got.length;
298+
});
299+
300+
expect(hits, 'a row must belong to exactly one day — 2 is the double-count this card is about').toBe(1);
301+
});
302+
}
303+
});
304+
305+
describe('#16179 — a bare `YYYY-MM-DD` end still means the WHOLE day (#4042 / #3777)', () => {
306+
// The other exclusive-end route, which this card must not disturb: a bare
307+
// day the CALLER wrote is still widened to `< nextUtcCalendarDay(day)`.
308+
for (const storage of STORAGE) {
309+
it(`${storage}: ['2026-09-06', '2026-09-06'] selects all of 2026-09-06 and nothing of the 7th`, async () => {
310+
const probes = [
311+
'2026-09-05T23:59:59.999Z',
312+
'2026-09-06T00:00:00.000Z',
313+
'2026-09-06T12:00:00.000Z',
314+
'2026-09-06T23:59:59.999Z',
315+
'2026-09-07T00:00:00.000Z',
316+
];
317+
await expect(
318+
probesSelected(probes, { range: ['2026-09-06', '2026-09-06'], storage }),
319+
).resolves.toEqual([
320+
'2026-09-06T00:00:00.000Z',
321+
'2026-09-06T12:00:00.000Z',
322+
'2026-09-06T23:59:59.999Z',
323+
]);
324+
});
325+
}
326+
});

0 commit comments

Comments
 (0)