From b94d3e9524e364e6e006e4f7b0294a35d644e4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 19:48:25 +0000 Subject: [PATCH 1/2] fix(driver-memory): make a resolved `dateRange` window's upper bound exclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...ics-date-range-token-end-exclusive.test.ts | 326 ++++++++++++++++++ .../driver-memory/src/memory-analytics.ts | 107 +++++- 2 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts new file mode 100644 index 0000000000..900f3af9eb --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16179 — `dateRange: 'today'` counted the first instant of TOMORROW. + * + * ## The defect, in one sentence + * + * `parseDateRangeString('today')` returns the day's start instant and the NEXT + * day's start instant; the 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 therefore + * one day PLUS ONE INSTANT long, two adjacent day windows overlapped at + * midnight, and a row stamped exactly there was counted in BOTH — silently, no + * error, no warning. + * + * ## ⭐ The control is the point of this file + * + * The card offered two routes and the second was taken: fix what the RELATIVE + * TOKENS emit, and ⛔ leave an explicit `dateRange: [a, b]` alone — `$lte` on a + * caller-written timestamp end is a PUBLISHED reading (`@objectstack/driver-memory` + * is a released package) and narrowing it is a decision nobody made. So every + * case below asks the SAME rows through BOTH arms of `AnalyticsDateRangeSchema` + * and asserts the difference between the two answers is EXACTLY the boundary + * instant — the token dropped it, the explicit array still keeps it. A repair + * that drifted into route 1 goes red here, on the explicit-array leg, not on + * the token leg. + * + * ## Why the assertions are about ROWS, and why both storage forms + * + * The window is internal; what a caller sees is which rows the answer counted, + * so every case drives the real public entry `MemoryAnalyticsService.query()` + * through `AnalyticsQuerySchema.parse`. And the call site builds TWO bounds + * joined by `$or` — one for `Date`-valued rows, one for ISO-string rows, the + * two forms an in-memory table really holds — so each case runs on both. A + * repair applied to one leg only passes half of this file. + * + * ## What this file deliberately does NOT pin + * + * - The `last N …` leg and the unresolved-preset fallback. The declared + * vocabulary spells its presets `last_7_days` while the parser matches + * `startsWith('last ')`, so every preset but `'today'` currently takes the + * `[range, range]` fallback — that mismatch is #16322 and the fallback's + * match-everything behaviour is #16041. ⛔ Neither is pinned here; pinning + * either would freeze a defect as a contract. + * - Which calendar the window is anchored to (#16042) and where a day BEGINS + * (#15825). Both are pinned by their own files. This file's cells carry a + * non-UTC zone only to prove the repair also holds where the upper bound is a + * ZONE's midnight instant rather than UTC's. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data'; + +const REAL_TZ = process.env.TZ; + +/** Run `fn` with the PROCESS on `zone` and the clock frozen at `instant`. */ +async function at(zone: string, instant: string, fn: () => Promise): Promise { + process.env.TZ = zone; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(instant)); + try { + return await fn(); + } finally { + vi.useRealTimers(); + if (REAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = REAL_TZ; + } +} + +const CUBE: Cube = { + name: 'events', + title: 'Events', + sql: 'events', + measures: { + count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, + }, + dimensions: { + probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, + createdAt: { + name: 'created_at', + label: 'Created At', + type: 'time', + sql: 'created_at', + granularities: ['day'], + }, + }, + public: true, +}; + +const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input); + +/** + * The two shapes an in-memory table really holds for a datetime — the `Date` + * a direct JS caller writes and the ISO string the driver's own `created_at` + * default and every REST/JSON write produce. The call site builds one bound + * for each and `$or`s them, so every case runs on both. + */ +const STORAGE = ['Date', 'ISO string'] as const; +type Storage = (typeof STORAGE)[number]; + +/** Ask `range` over rows planted at `instants`; answer which probes came back. */ +async function probesSelected( + instants: string[], + opts: { range: AnalyticsDateRange; timezone?: string; storage: Storage }, +): Promise { + const driver = new InMemoryDriver({ + initialData: { + events: instants.map((iso, i) => ({ + id: i + 1, + probe: iso, + created_at: opts.storage === 'Date' ? new Date(iso) : iso, + })), + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const query: AnalyticsQuery = { + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: opts.range }], + }; + if (opts.timezone !== undefined) query.timezone = opts.timezone; + const result = await service.query(asQuery(query)); + return result.rows.map((row) => String(row['events.probe'])).sort(); +} + +const ms = (iso: string) => Date.parse(iso); +const iso = (t: number) => new Date(t).toISOString(); + +/** + * What `zone`'s local clock reads at `instant`, from the platform tz database + * ALONE — ⛔ never from the primitives the repair uses. This is what makes the + * window literals below data rather than a second implementation: each one is + * asserted to be a local midnight by this function. + */ +function localClock(instant: string, zone: string): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + fractionalSecondDigits: 3, + }).format(new Date(instant)); +} + +interface Cell { + zone: string; + /** Frozen clock, always written in UTC. */ + instant: string; + /** `'today'` on `zone`'s calendar — the half-open `[start, end)` it MUST be. */ + window: [string, string]; +} + +/** + * UTC (where the bound is UTC midnight), a zone AHEAD of UTC whose day boundary + * is a plain non-UTC instant, one BEHIND, and one whose offset is not a whole + * number of hours — so a repair that quietly re-derived the bound as a UTC day + * cannot pass. Every `window` literal is checked against `Intl` below. + */ +const CELLS: Cell[] = [ + { zone: 'UTC', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z'] }, + { zone: 'Asia/Shanghai', instant: '2026-09-06T20:00:00Z', window: ['2026-09-06T16:00:00.000Z', '2026-09-07T16:00:00.000Z'] }, + { zone: 'America/Denver', instant: '2026-09-06T12:00:00Z', window: ['2026-09-06T06:00:00.000Z', '2026-09-07T06:00:00.000Z'] }, + { zone: 'Asia/Kolkata', instant: '2026-09-06T12:00:00Z', window: ['2026-09-05T18:30:00.000Z', '2026-09-06T18:30:00.000Z'] }, +]; + +const label = (c: Cell) => `${c.zone} @ ${c.instant}`; + +/** + * Probes around the window's UPPER bound, which is the only place this card + * lives: the last instant inside, the boundary instant itself, the first + * instant after — plus an unambiguous midday anchor and the window's own start, + * so a repair that broke the LOWER bound cannot pass either. + */ +function probesFor(c: Cell): string[] { + const [start, end] = c.window.map(ms); + return [...new Set([ + start, + start + Math.floor((end - start) / 2), + end - 1, + end, // ⭐ THE instant this card is about + end + 1, + ])].map(iso); +} + +/** The rows a half-open `[start, end)` window contains. */ +const halfOpen = (probes: string[], w: [string, string]) => + probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) < ms(w[1])).sort(); + +/** The rows a closed `[start, end]` window contains — today's explicit-array reading. */ +const closed = (probes: string[], w: [string, string]) => + probes.filter((p) => ms(p) >= ms(w[0]) && ms(p) <= ms(w[1])).sort(); + +describe("#16179 — 'today' stops BEFORE tomorrow's first instant", () => { + for (const c of CELLS) { + for (const storage of STORAGE) { + it(`${label(c)} · ${storage}: the row at the next day's 00:00:00.000 is NOT counted`, async () => { + const probes = probesFor(c); + const boundary = c.window[1]; + + // CONTROL FIRST — a probe set that never touches the boundary + // would pass while asserting nothing about this card. + expect(probes, `${label(c)}: the boundary instant must be planted`).toContain(boundary); + + const expected = halfOpen(probes, c.window); + expect(expected, 'the boundary instant must be OUTSIDE the expected set').not.toContain(boundary); + + await at(c.zone, c.instant, async () => { + await expect( + probesSelected(probes, { range: 'today', timezone: c.zone, storage }), + ).resolves.toEqual(expected); + }); + }); + + it(`${label(c)} · ${storage}: ⭐ an explicit [a, b] over the SAME window still counts b`, async () => { + const probes = probesFor(c); + const explicit: AnalyticsDateRange = [c.window[0], c.window[1]]; + + // The published reading, unchanged: a caller-written timestamp + // end is INCLUSIVE. ⛔ This is route 1's tripwire — a repair + // that made `$lt` unconditional reddens HERE. + const expected = closed(probes, c.window); + expect(expected, 'the control must include the boundary instant').toContain(c.window[1]); + + await at(c.zone, c.instant, async () => { + await expect( + probesSelected(probes, { range: explicit, timezone: c.zone, storage }), + ).resolves.toEqual(expected); + }); + }); + + it(`${label(c)} · ${storage}: the two arms differ by EXACTLY the boundary instant`, async () => { + const probes = probesFor(c); + const explicit: AnalyticsDateRange = [c.window[0], c.window[1]]; + + await at(c.zone, c.instant, async () => { + const token = await probesSelected(probes, { range: 'today', timezone: c.zone, storage }); + const array = await probesSelected(probes, { range: explicit, timezone: c.zone, storage }); + + // Stated as data: the repair removed one instant from the + // token's answer and nothing at all from the array's. + expect(array.filter((p) => !token.includes(p))).toEqual([c.window[1]]); + expect(token.filter((p) => !array.includes(p))).toEqual([]); + }); + }); + } + } + + it('every window literal is a local midnight in its own zone — checked against `Intl`, not against the code under test', () => { + for (const c of CELLS) { + for (const bound of c.window) { + expect( + localClock(bound, c.zone), + `${label(c)}: ${bound} is not midnight in ${c.zone}`, + ).toMatch(/ 00:00:00\.000$/); + } + } + }); + + it('every window is exactly one calendar day apart and the cells are not all UTC', () => { + for (const c of CELLS) { + const [start, end] = c.window.map(ms); + expect(end - start, `${label(c)}: window is not 24h`).toBe(86_400_000); + } + const nonUtcBoundaries = CELLS.filter((c) => !c.window[1].endsWith('T00:00:00.000Z')); + expect( + nonUtcBoundaries.length, + 'every cell ends at UTC midnight, so a repair that re-derived the bound as a UTC day would pass', + ).toBeGreaterThan(0); + }); +}); + +describe('#16179 — two adjacent day windows PARTITION the midnight instant', () => { + // The double-count, driven end to end: the same row, the same table, asked + // on two consecutive days. Before the repair it answered `2` — counted by + // the day it ends and by the day it begins. + const ZONE = 'Asia/Shanghai'; + const MIDNIGHT = '2026-09-06T16:00:00.000Z'; // where 2026-09-07 begins in Shanghai + + for (const storage of STORAGE) { + it(`${storage}: the row at midnight is counted by exactly one of the two days`, async () => { + const probes = [MIDNIGHT]; + let hits = 0; + + // The day that ENDS at MIDNIGHT. + await at(ZONE, '2026-09-06T12:00:00Z', async () => { + const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage }); + hits += got.length; + }); + // The day that BEGINS at MIDNIGHT. + await at(ZONE, '2026-09-07T02:00:00Z', async () => { + const got = await probesSelected(probes, { range: 'today', timezone: ZONE, storage }); + hits += got.length; + }); + + expect(hits, 'a row must belong to exactly one day — 2 is the double-count this card is about').toBe(1); + }); + } +}); + +describe('#16179 — a bare `YYYY-MM-DD` end still means the WHOLE day (#4042 / #3777)', () => { + // The other exclusive-end route, which this card must not disturb: a bare + // day the CALLER wrote is still widened to `< nextUtcCalendarDay(day)`. + for (const storage of STORAGE) { + it(`${storage}: ['2026-09-06', '2026-09-06'] selects all of 2026-09-06 and nothing of the 7th`, async () => { + const probes = [ + '2026-09-05T23:59:59.999Z', + '2026-09-06T00:00:00.000Z', + '2026-09-06T12:00:00.000Z', + '2026-09-06T23:59:59.999Z', + '2026-09-07T00:00:00.000Z', + ]; + await expect( + probesSelected(probes, { range: ['2026-09-06', '2026-09-06'], storage }), + ).resolves.toEqual([ + '2026-09-06T00:00:00.000Z', + '2026-09-06T12:00:00.000Z', + '2026-09-06T23:59:59.999Z', + ]); + }); + } +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index e2a48c2fa6..0315a768e1 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -622,6 +622,40 @@ export interface MemoryAnalyticsConfig { logger?: Logger; } +/** + * [#16179] A `timeDimensions[].dateRange` resolved to its two bounds, together + * with what the UPPER one means. + * + * `AnalyticsDateRange` is a union of two arms (`AnalyticsDateRangeSchema`) and + * they do NOT agree on that question, which is the whole reason this carries a + * flag instead of a bare pair: + * + * - an explicit `[a, b]` is the CALLER's window, and `b` is a bound they wrote + * meaning "include b" — the reading `$lte` has published since this face + * existed. ⛔ Never narrow it here; doing so is a silent behaviour change on + * a published package, and it is the route this card deliberately did not + * take; + * - a preset name is resolved BY this driver, and `'today'`'s upper bound is + * the first instant of TOMORROW — the day's exclusive end, not a moment the + * day contains. Compared with `$lte` it made `'today'` one day plus one + * instant long, so two adjacent day windows overlapped at midnight and a row + * stamped there was counted in BOTH. + * + * The distinction is available where it is made — one line above the bound + * construction, at the `Array.isArray` that discriminates the union's arms — + * so the two paths never had to share an answer. + */ +interface ResolvedDateRange { + /** `[start, end]`, in the spelling the bounds are compared as. */ + readonly bounds: readonly string[]; + /** + * Is `end` the first instant AFTER the window rather than its last instant? + * `true` only for a window this driver RESOLVED; ⛔ never for one a caller + * wrote out. + */ + readonly endExclusive: boolean; +} + /** * Memory-Based Analytics Service * @@ -743,9 +777,15 @@ export class MemoryAnalyticsService implements IAnalyticsService { for (const timeDim of query.timeDimensions) { const fieldPath = this.resolveFieldPath(cube, timeDim.dimension); if (timeDim.dateRange) { - const range = Array.isArray(timeDim.dateRange) - ? timeDim.dateRange + // [#16179] The union's two arms are discriminated HERE, and the + // answer travels the two lines down to the bound construction rather + // than being re-derived from the bounds themselves — which is not + // possible, because a resolved window and a caller's window are + // rendered identically (`toISOString()` on both sides). + const resolved: ResolvedDateRange = Array.isArray(timeDim.dateRange) + ? { bounds: timeDim.dateRange, endExclusive: false } : this.parseDateRangeString(timeDim.dateRange, query.timezone); + const range = resolved.bounds; if (range.length === 2) { // The window matches BOTH stored forms of a datetime value — the @@ -763,12 +803,31 @@ export class MemoryAnalyticsService implements IAnalyticsService { // inherits `<= day`'s whole-day intent via `< nextDay`. const start = String(range[0]); const end = String(range[1]); - const nextDay = nextUtcCalendarDay(end); - const stringBounds = nextDay != null - ? { $gte: start, $lt: nextDay } + // [#16179] The upper bound is EXCLUSIVE by exactly two routes, and + // they are mutually exclusive by construction: + // + // - the RESOLVER produced the window, so `end` is already the + // instant the window stops before -- `'today'`'s end is the + // first instant of tomorrow. ⛔ It must NOT be widened again: + // it is an instant, so `nextUtcCalendarDay` refuses it anyway + // (`calendar-day.ts`, pinned by `calendar-day.test.ts`), and + // asking is what would make a future bare-day resolver widen a + // bound that was already exclusive. + // - the CALLER wrote a bare `YYYY-MM-DD`, which denotes the WHOLE + // day and widens to `< nextDay` (#4042; the SQL twin is #3777). + // + // Anything else -- a full timestamp the CALLER wrote -- keeps + // instant semantics and stays INCLUSIVE, byte for byte as before. + const widenedDay = resolved.endExclusive ? null : nextUtcCalendarDay(end); + const upperString = resolved.endExclusive ? end : widenedDay; + const upperDate = widenedDay != null + ? new Date(`${widenedDay}T00:00:00.000Z`) + : (resolved.endExclusive ? new Date(end) : null); + const stringBounds = upperString != null + ? { $gte: start, $lt: upperString } : { $gte: start, $lte: end }; - const dateBounds = nextDay != null - ? { $gte: new Date(start), $lt: new Date(`${nextDay}T00:00:00.000Z`) } + const dateBounds = upperDate != null + ? { $gte: new Date(start), $lt: upperDate } : { $gte: new Date(start), $lte: new Date(end) }; pipeline.push({ $match: { @@ -1418,10 +1477,15 @@ export class MemoryAnalyticsService implements IAnalyticsService { return sql.trim(); } - private parseDateRangeString(range: string, timezone?: string): string[] { + private parseDateRangeString(range: string, timezone?: string): ResolvedDateRange { // Simple parser for common date range strings // In production, this would use a proper date range parser // + // [#16179] Returns a {@link ResolvedDateRange}, not a bare pair: a window + // this function BUILT knows whether its upper bound is inclusive, and that + // answer cannot be recovered downstream -- a resolved bound and a caller's + // bound are the same `toISOString()` text. Each `return` below states it. + // // [#15825] ONE calendar, and it is UTC -- the same one `toISOString()` // renders every bound below on. Two INDEPENDENT defects lived here: // @@ -1512,7 +1576,20 @@ export class MemoryAnalyticsService implements IAnalyticsService { // The next calendar day, via the proxy calendar -- never `+ 86_400_000`. const tomorrow = new Date(today.getTime()); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - return [boundary(today), boundary(tomorrow)]; + // [#16179] `endExclusive`, and the flag is the whole repair: the upper + // bound is where TOMORROW begins, which is the one instant `'today'` must + // NOT contain. Compared inclusively it made every day window one instant + // too long, so two adjacent days overlapped at midnight and a row stamped + // there was counted twice -- silently, with no error and no warning. + // + // ⛔ Emitting a bare `YYYY-MM-DD` end instead -- the other spelling of + // this repair -- is NOT available on this path and the failure would be + // silent: `boundary()` renders that zone's midnight INSTANT, and a bare + // day would be widened by `nextUtcCalendarDay` and cut at `T00:00:00Z`, + // i.e. at UTC midnight, undoing #16042 for every non-UTC caller. Measured + // on `Asia/Shanghai`: the window ends at 2026-09-06T16:00:00.000Z, and + // the bare-day route would end it eight hours late. + return { bounds: [boundary(today), boundary(tomorrow)], endExclusive: true }; } else if (range.startsWith('last ')) { const parts = range.split(' '); const num = parseInt(parts[1]); @@ -1529,11 +1606,17 @@ export class MemoryAnalyticsService implements IAnalyticsService { start.setUTCFullYear(start.getUTCFullYear() - num); } - // The upper bound is the current INSTANT, which no zone moves. - return [boundary(start), now.toISOString()]; + // The upper bound is the current INSTANT, which no zone moves -- and it + // is a moment the window REACHES, not one it stops before, so it stays + // INCLUSIVE ([#16179] leaves this leg alone). ⚠️ No preset in the declared + // vocabulary reaches this branch today: `DATE_RANGE_PRESETS` spells them + // `last_7_days`, and `startsWith('last ')` wants a space (#16322). + return { bounds: [boundary(start), now.toISOString()], endExclusive: false }; } - return [range, range]; // Fallback + // Fallback -- an inclusive pair of the raw string, unchanged and NOT this + // card's question (#16041 / #16322 own what an unresolved range matches). + return { bounds: [range, range], endExclusive: false }; } private generateSqlFromPipeline(table: string, pipeline: Record[]): string { From 17921d98d56479eab09e080a9207b75e58735557 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 20:08:27 +0000 Subject: [PATCH 2/2] test(driver-memory): pin the exclusive token end, and add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...analytics-date-range-token-end-exclusive.md | 18 ++++++++++++++++++ ...tics-date-range-token-end-exclusive.test.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .changeset/memory-analytics-date-range-token-end-exclusive.md diff --git a/.changeset/memory-analytics-date-range-token-end-exclusive.md b/.changeset/memory-analytics-date-range-token-end-exclusive.md new file mode 100644 index 0000000000..f6bb9d5572 --- /dev/null +++ b/.changeset/memory-analytics-date-range-token-end-exclusive.md @@ -0,0 +1,18 @@ +--- +"@objectstack/driver-memory": patch +--- + +`driver-memory` analytics: `dateRange: 'today'` no longer counts the first instant of tomorrow (#16179) + +`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. + +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: + +| `dateRange` | before | after | +|:--|:--|:--| +| `'today'` | all three, including `2026-09-07T00:00:00.000Z` | the first two | +| `['2026-09-06T00:00:00.000Z', '2026-09-07T00:00:00.000Z']` | all three | all three — **unchanged** | + +⭐ **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)`). + +⚠️ 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. diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts index 900f3af9eb..9a134cdc2c 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-token-end-exclusive.test.ts @@ -138,12 +138,14 @@ const iso = (t: number) => new Date(t).toISOString(); * asserted to be a local midnight by this function. */ function localClock(instant: string, zone: string): string { + // ⛔ No `fractionalSecondDigits`: it is not in this package's `lib` view of + // `Intl.DateTimeFormatOptions`. The sub-second half is checked directly on + // the literal instead — see the fence below. return new Intl.DateTimeFormat('en-CA', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', - fractionalSecondDigits: 3, }).format(new Date(instant)); } @@ -253,10 +255,13 @@ describe("#16179 — 'today' stops BEFORE tomorrow's first instant", () => { it('every window literal is a local midnight in its own zone — checked against `Intl`, not against the code under test', () => { for (const c of CELLS) { for (const bound of c.window) { + // The clock half, from the tz database. expect( localClock(bound, c.zone), `${label(c)}: ${bound} is not midnight in ${c.zone}`, - ).toMatch(/ 00:00:00\.000$/); + ).toMatch(/ 00:00:00$/); + // The sub-second half, read off the literal itself. + expect(bound, `${label(c)}: ${bound} carries a sub-second part`).toMatch(/\.000Z$/); } } });