Skip to content

Commit db4bf90

Browse files
os-muskclaude
andauthored
fix(driver-memory): a resolved dateRange window's upper bound is exclusive, so 'today' stops before tomorrow (#16966)
* 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 * test(driver-memory): pin the exclusive token end, and add the changeset The `Intl.DateTimeFormat` fence dropped `fractionalSecondDigits` — it is not in this package's `lib` view of `DateTimeFormatOptions` — and reads the sub-second half off the window literal instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b6fe3f commit db4bf90

3 files changed

Lines changed: 444 additions & 12 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
`driver-memory` analytics: `dateRange: 'today'` no longer counts the first instant of tomorrow (#16179)
6+
7+
`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 a full timestamp — its documented contract — so the half-open branch was never taken and the window closed at both ends. `'today'` was one day **plus one instant** long: two adjacent day windows overlapped at midnight and a row stamped exactly there was counted in **both**, silently.
8+
9+
Measured through `MemoryAnalyticsService.query()` against the built package, rows at `2026-09-06T00:00:00.000Z` / `2026-09-06T12:00:00.000Z` / `2026-09-07T00:00:00.000Z`, clock frozen inside 2026-09-06:
10+
11+
| `dateRange` | before | after |
12+
|:--|:--|:--|
13+
| `'today'` | all three, including `2026-09-07T00:00:00.000Z` | the first two |
14+
| `['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z']` | all three | all three — **unchanged** |
15+
16+
⭐ **An explicit `dateRange: [a, b]` is deliberately untouched.** `$lte` on a caller-written timestamp end is the reading this package publishes today, and narrowing it would silently change what an existing query answers; the repair is confined to what the driver's own preset resolution emits. A bare-day end likewise keeps its whole-day widening (`< nextUtcCalendarDay(day)`).
17+
18+
⚠️ Behaviour change for a caller using `dateRange: 'today'`: a row stamped at exactly the next day's midnight — `00:00:00.000` in the query's timezone — moves out of today's answer and into tomorrow's. That is the double-count being removed, not coverage being lost.
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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+
// ⛔ No `fractionalSecondDigits`: it is not in this package's `lib` view of
142+
// `Intl.DateTimeFormatOptions`. The sub-second half is checked directly on
143+
// the literal instead — see the fence below.
144+
return new Intl.DateTimeFormat('en-CA', {
145+
timeZone: zone,
146+
hourCycle: 'h23',
147+
year: 'numeric', month: '2-digit', day: '2-digit',
148+
hour: '2-digit', minute: '2-digit', second: '2-digit',
149+
}).format(new Date(instant));
150+
}
151+
152+
interface Cell {
153+
zone: string;
154+
/** Frozen clock, always written in UTC. */
155+
instant: string;
156+
/** `'today'` on `zone`'s calendar — the half-open `[start, end)` it MUST be. */
157+
window: [string, string];
158+
}
159+
160+
/**
161+
* UTC (where the bound is UTC midnight), a zone AHEAD of UTC whose day boundary
162+
* is a plain non-UTC instant, one BEHIND, and one whose offset is not a whole
163+
* number of hours — so a repair that quietly re-derived the bound as a UTC day
164+
* cannot pass. Every `window` literal is checked against `Intl` below.
165+
*/
166+
const CELLS: Cell[] = [
167+
{ zone: 'UTC', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'] },
168+
{ zone: 'Asia/Shanghai', instant: '2026-09-06T20:00:00Z', window: ['2026-09-06T16:00:00.000Z', '2026-09-07T16:00:00.000Z'] },
169+
{ zone: 'America/Denver', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T06:00:00.000Z', '2026-09-07T06:00:00.000Z'] },
170+
{ zone: 'Asia/Kolkata', instant: '2026-09-06T12:00:00Z', window: ['2026-09-05T18:30:00.000Z', '2026-09-06T18:30:00.000Z'] },
171+
];
172+
173+
const label = (c: Cell) => `${c.zone} @ ${c.instant}`;
174+
175+
/**
176+
* Probes around the window's UPPER bound, which is the only place this card
177+
* lives: the last instant inside, the boundary instant itself, the first
178+
* instant after — plus an unambiguous midday anchor and the window's own start,
179+
* so a repair that broke the LOWER bound cannot pass either.
180+
*/
181+
function probesFor(c: Cell): string[] {
182+
const [start, end] = c.window.map(ms);
183+
return [...new Set([
184+
start,
185+
start + Math.floor((end - start) / 2),
186+
end - 1,
187+
end, // ⭐ THE instant this card is about
188+
end + 1,
189+
])].map(iso);
190+
}
191+
192+
/** The rows a half-open `[start, end)` window contains. */
193+
const halfOpen = (probes: string[], w: [string, string]) =>
194+
probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) < ms(w[1])).sort();
195+
196+
/** The rows a closed `[start, end]` window contains — today's explicit-array reading. */
197+
const closed = (probes: string[], w: [string, string]) =>
198+
probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) <= ms(w[1])).sort();
199+
200+
describe("#16179 — 'today' stops BEFORE tomorrow's first instant", () => {
201+
for (const c of CELLS) {
202+
for (const storage of STORAGE) {
203+
it(`${label(c)} · ${storage}: the row at the next day's 00:00:00.000 is NOT counted`, async () => {
204+
const probes = probesFor(c);
205+
const boundary = c.window[1];
206+
207+
// CONTROL FIRST — a probe set that never touches the boundary
208+
// would pass while asserting nothing about this card.
209+
expect(probes, `${label(c)}: the boundary instant must be planted`).toContain(boundary);
210+
211+
const expected = halfOpen(probes, c.window);
212+
expect(expected, 'the boundary instant must be OUTSIDE the expected set').not.toContain(boundary);
213+
214+
await at(c.zone, c.instant, async () => {
215+
await expect(
216+
probesSelected(probes, { range: 'today', timezone: c.zone, storage }),
217+
).resolves.toEqual(expected);
218+
});
219+
});
220+
221+
it(`${label(c)} · ${storage}: ⭐ an explicit [a, b] over the SAME window still counts b`, async () => {
222+
const probes = probesFor(c);
223+
const explicit: AnalyticsDateRange = [c.window[0], c.window[1]];
224+
225+
// The published reading, unchanged: a caller-written timestamp
226+
// end is INCLUSIVE. ⛔ This is route 1's tripwire — a repair
227+
// that made `$lt` unconditional reddens HERE.
228+
const expected = closed(probes, c.window);
229+
expect(expected, 'the control must include the boundary instant').toContain(c.window[1]);
230+
231+
await at(c.zone, c.instant, async () => {
232+
await expect(
233+
probesSelected(probes, { range: explicit, timezone: c.zone, storage }),
234+
).resolves.toEqual(expected);
235+
});
236+
});
237+
238+
it(`${label(c)} · ${storage}: the two arms differ by EXACTLY the boundary instant`, async () => {
239+
const probes = probesFor(c);
240+
const explicit: AnalyticsDateRange = [c.window[0], c.window[1]];
241+
242+
await at(c.zone, c.instant, async () => {
243+
const token = await probesSelected(probes, { range: 'today', timezone: c.zone, storage });
244+
const array = await probesSelected(probes, { range: explicit, timezone: c.zone, storage });
245+
246+
// Stated as data: the repair removed one instant from the
247+
// token's answer and nothing at all from the array's.
248+
expect(array.filter((p) => !token.includes(p))).toEqual([c.window[1]]);
249+
expect(token.filter((p) => !array.includes(p))).toEqual([]);
250+
});
251+
});
252+
}
253+
}
254+
255+
it('every window literal is a local midnight in its own zone — checked against `Intl`, not against the code under test', () => {
256+
for (const c of CELLS) {
257+
for (const bound of c.window) {
258+
// The clock half, from the tz database.
259+
expect(
260+
localClock(bound, c.zone),
261+
`${label(c)}: ${bound} is not midnight in ${c.zone}`,
262+
).toMatch(/ 00:00:00$/);
263+
// The sub-second half, read off the literal itself.
264+
expect(bound, `${label(c)}: ${bound} carries a sub-second part`).toMatch(/\.000Z$/);
265+
}
266+
}
267+
});
268+
269+
it('every window is exactly one calendar day apart and the cells are not all UTC', () => {
270+
for (const c of CELLS) {
271+
const [start, end] = c.window.map(ms);
272+
expect(end - start, `${label(c)}: window is not 24h`).toBe(86_400_000);
273+
}
274+
const nonUtcBoundaries = CELLS.filter((c) => !c.window[1].endsWith('T00:00:00.000Z'));
275+
expect(
276+
nonUtcBoundaries.length,
277+
'every cell ends at UTC midnight, so a repair that re-derived the bound as a UTC day would pass',
278+
).toBeGreaterThan(0);
279+
});
280+
});
281+
282+
describe('#16179 — two adjacent day windows PARTITION the midnight instant', () => {
283+
// The double-count, driven end to end: the same row, the same table, asked
284+
// on two consecutive days. Before the repair it answered `2` — counted by
285+
// the day it ends and by the day it begins.
286+
const ZONE = 'Asia/Shanghai';
287+
const MIDNIGHT = '2026-09-06T16:00:00.000Z'; // where 2026-09-07 begins in Shanghai
288+
289+
for (const storage of STORAGE) {
290+
it(`${storage}: the row at midnight is counted by exactly one of the two days`, async () => {
291+
const probes = [MIDNIGHT];
292+
let hits = 0;
293+
294+
// The day that ENDS at MIDNIGHT.
295+
await at(ZONE, '2026-09-06T12:00:00Z', async () => {
296+
const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage });
297+
hits += got.length;
298+
});
299+
// The day that BEGINS at MIDNIGHT.
300+
await at(ZONE, '2026-09-07T02:00:00Z', async () => {
301+
const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage });
302+
hits += got.length;
303+
});
304+
305+
expect(hits, 'a row must belong to exactly one day — 2 is the double-count this card is about').toBe(1);
306+
});
307+
}
308+
});
309+
310+
describe('#16179 — a bare `YYYY-MM-DD` end still means the WHOLE day (#4042 / #3777)', () => {
311+
// The other exclusive-end route, which this card must not disturb: a bare
312+
// day the CALLER wrote is still widened to `< nextUtcCalendarDay(day)`.
313+
for (const storage of STORAGE) {
314+
it(`${storage}: ['2026-09-06', '2026-09-06'] selects all of 2026-09-06 and nothing of the 7th`, async () => {
315+
const probes = [
316+
'2026-09-05T23:59:59.999Z',
317+
'2026-09-06T00:00:00.000Z',
318+
'2026-09-06T12:00:00.000Z',
319+
'2026-09-06T23:59:59.999Z',
320+
'2026-09-07T00:00:00.000Z',
321+
];
322+
await expect(
323+
probesSelected(probes, { range: ['2026-09-06', '2026-09-06'], storage }),
324+
).resolves.toEqual([
325+
'2026-09-06T00:00:00.000Z',
326+
'2026-09-06T12:00:00.000Z',
327+
'2026-09-06T23:59:59.999Z',
328+
]);
329+
});
330+
}
331+
});

0 commit comments

Comments
 (0)