diff --git a/.changeset/memory-analytics-date-range-utc-window.md b/.changeset/memory-analytics-date-range-utc-window.md new file mode 100644 index 0000000000..11a57cf6c3 --- /dev/null +++ b/.changeset/memory-analytics-date-range-utc-window.md @@ -0,0 +1,62 @@ +--- +"@objectstack/driver-memory": minor +--- + +`driver-memory` analytics resolves `dateRange` on the UTC calendar, so `'today'` and `last N ...` stop being offset by the process timezone (#15825) + +`MemoryAnalyticsService.query()` lowers a string `dateRange` through +`parseDateRangeString()`, and that function built its window on the **local** +calendar and rendered it as **UTC**. Two independent defects lived in it. + +**1. The window boundary was local midnight.** `new Date(y, m, d)` constructs +local midnight; `toISOString()` renders that instant in UTC. So in any process +not sitting at UTC, the `'today'` bucket was the **local** day expressed as a +UTC range. Measured 2026-09-05, with the clock at `2026-09-05T20:51Z`: + +| `TZ` | `'today'` window produced | the UTC day it should be | +|:---|:---|:---| +| `UTC` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | (agrees) | +| `Asia/Shanghai` | `2026-09-05T16:00Z` → `2026-09-06T16:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | +| `America/Los_Angeles` | `2026-09-05T07:00Z` → `2026-09-06T07:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | +| `Europe/Berlin` | `2026-09-04T22:00Z` → `2026-09-05T22:00Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | +| `Asia/Kolkata` | `2026-09-05T18:30Z` → `2026-09-06T18:30Z` | `2026-09-05T00:00Z` → `2026-09-06T00:00Z` | + +That is wrong on **every day of the year**, with no DST transition needed. + +**2. The `last N ...` legs mixed two calendars.** `setDate(getDate() - n)` is +local arithmetic and `toISOString()` is a UTC rendering. `setDate` preserves +wall-clock time, so the instant moves `n × 24h` only while every local day in +the window is 24 hours long; across a DST transition it moves 23h or 25h and +the window start slips an hour. `setMonth` / `setFullYear` are the same class, +and can move it by a whole day: at `America/New_York` with the clock at +`2026-01-01T12:00Z`, `last 1 month` started at `2025-12-02T00:00Z` instead of +`2025-12-01T00:00Z`. + +**⛔ The two do not fix each other**, which is the easiest thing to get wrong +here: `setUTCDate` alone leaves the local-midnight boundary in place, and +`Date.UTC` alone leaves the arithmetic mixed. Both are repaired, each is pinned +by its own file, and each was ablated on its own to prove the separation. + +**Why UTC and not "any consistent calendar".** The rest of the platform +resolves a bare date to the UTC day — `@objectstack/core`'s `{today}` +filter-token macro builds its reference day as +`new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts when the +context carries no timezone, and `{TODAY()}` in flow templates resolves to the +UTC day (#14852 repaired the identical two-calendar shape there). Before this +change the same analytics question asked through the driver's `dateRange` and +through a flow token could select **different rows in one deployment**. UTC is +also the terminal fallback of the engine's own resolution chain +(`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2). + +**What did not change.** The parser's vocabulary, its `[range, range]` +fallback, and the shape of the emitted `$match` are untouched — this is a +calendar repair, not a rewrite. `AnalyticsQuery.timezone` is still not consulted +by this path; making the range tokens timezone-**aware** is a separate and +larger question, which #14852 also declined. + +**Who sees a difference.** Any deployment whose process is not at UTC: `'today'` +and `last N ...` now select the UTC day they always claimed to, so charts built +on a string `dateRange` shift by the process offset — toward agreement with +`{today}` / `{TODAY()}` and with the same query run at `TZ=UTC`. Deployments +already running at UTC are unaffected; the two spellings are indistinguishable +there, which is exactly why CI never reddened on this. diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts new file mode 100644 index 0000000000..dde996638d --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15825 — DEFECT 2 of 2: the `last N ...` legs of `parseDateRangeString()` + * did their arithmetic on the LOCAL calendar and rendered it on the UTC one. + * + * ## ⛔ Why this file cannot be written to run only at `TZ=UTC` + * + * The two spellings — local `setDate(getDate() - n)` and UTC + * `setUTCDate(getUTCDate() - n)` — are behaviourally INDISTINGUISHABLE at + * `TZ=UTC`, which is precisely why nothing in CI ever went red on this and why + * it shipped. Every case below therefore fakes BOTH halves of the environment: + * a DST-observing zone (`process.env.TZ`, re-read by V8 on the next `Date` + * operation) AND an instant whose day/month/year shift crosses that zone's own + * transition. + * + * ## The mechanism, stated once + * + * `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n + * days moves the INSTANT by exactly n x 24h only while every local day in the + * window is 24 hours long. Across a spring-forward that window is 23 hours; + * across a fall-back, 25. The rendering is `toISOString()` — UTC. So the + * window start slips an hour, and rows in that hour are wrongly gained or + * wrongly lost. `setMonth` / `setFullYear` are the same class, and the + * `last 1 month` cell below shows the local calendar can move the answer by a + * whole DAY, not merely an hour. + * + * ## ⭐ This is a SEPARATE defect from the window boundary + * + * Defect 1 — `new Date(y, m, d)` building LOCAL midnight — is pinned in + * `memory-analytics-date-range-utc-window.test.ts`. ⛔ Neither repair fixes the + * other, and each was ablated on its own to prove it. Every cell here is built + * on a boundary that is ALREADY `Date.UTC`, so what it measures is the + * arithmetic leg alone. + * + * ## The oracle is timezone-INVARIANCE, not a second implementation + * + * For `month` and `year` there is no offset-free definition of the answer to + * compare against, so re-deriving one would just be the fix written twice. The + * oracle used instead is the property the card is actually about: at `TZ=UTC` + * the two spellings coincide, so the `TZ=UTC` run IS the reference answer, and + * a correct implementation must return exactly that answer in every other + * zone. Where a definition does exist (`day` / `week`, since a UTC day is + * always 24h) it is asserted as well. + * + * ## The inline control is load-bearing + * + * Each cell also evaluates the OLD spelling directly and asserts it DISAGREES + * with the one-calendar answer. Without it a green run would be ambiguous + * between "the fix works" and "these instants are not actually in a transition + * window" — the second being the failure mode that hid this bug for so long. + */ + +import { describe, it, expect } from 'vitest'; +import { vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { 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; + } +} + +type Unit = 'day' | 'week' | 'month' | 'year'; + +/** The window boundary, already repaired — defect 1 is not what this file measures. */ +function utcBoundary(): Date { + const n = new Date(); + return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate())); +} + +/** The DEFECT, spelled out: UTC boundary, arithmetic on the LOCAL calendar. */ +function localArithmeticStart(unit: Unit, num: number): string { + const s = utcBoundary(); + if (unit === 'day') s.setDate(s.getDate() - num); + else if (unit === 'week') s.setDate(s.getDate() - num * 7); + else if (unit === 'month') s.setMonth(s.getMonth() - num); + else s.setFullYear(s.getFullYear() - num); + return s.toISOString(); +} + +/** + * ⚠️ Used ONLY to place probe rows on either side of the two candidate + * boundaries — never as the oracle. The oracle is the `TZ=UTC` run below. + */ +function utcArithmeticStart(unit: Unit, num: number): string { + const s = utcBoundary(); + if (unit === 'day') s.setUTCDate(s.getUTCDate() - num); + else if (unit === 'week') s.setUTCDate(s.getUTCDate() - num * 7); + else if (unit === 'month') s.setUTCMonth(s.getUTCMonth() - num); + else s.setUTCFullYear(s.getUTCFullYear() - num); + return s.toISOString(); +} + +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); + +/** Ask `range` over rows planted at `instants`; answer which probes came back. */ +async function probesSelected(instants: string[], range: string): Promise { + const driver = new InMemoryDriver({ + initialData: { + events: instants.map((iso, i) => ({ + id: i + 1, + probe: iso, + created_at: new Date(iso), + })), + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query(asQuery({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], + })); + return result.rows.map((row) => String(row['events.probe'])).sort(); +} + +interface Cell { + zone: string; + /** Frozen clock, always written in UTC. */ + instant: string; + range: string; + unit: Unit; + num: number; + kind: 'spring-forward' | 'fall-back'; +} + +/** + * Red cells — every one MEASURED, ⛔ not guessed: each is an instant at which + * the local-arithmetic spelling actually disagrees with the one-calendar + * answer in that zone, taken from a 9-zone x 366-day sweep of 2026 across all + * four units (2026-09-05). Both hemispheres, both transition directions, all + * four legs (`day` / `week` / `month` / `year`), and two zones whose standard + * offset is not a whole hour (St_Johns -03:30, Chatham +12:45) so a + * whole-hour assumption cannot hide in the repair. + */ +const DST_CELLS: Cell[] = [ + { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' }, + { zone: 'America/Los_Angeles', instant: '2026-03-09T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' }, + { zone: 'America/St_Johns', instant: '2026-03-09T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'spring-forward' }, + { zone: 'Europe/London', instant: '2026-03-30T12:00:00Z', range: 'last 7 days', unit: 'day', num: 7, kind: 'spring-forward' }, + { zone: 'America/New_York', instant: '2026-03-09T12:00:00Z', range: 'last 1 week', unit: 'week', num: 1, kind: 'spring-forward' }, + { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'spring-forward' }, + { zone: 'Australia/Sydney', instant: '2026-04-05T12:00:00Z', range: 'last 3 days', unit: 'day', num: 3, kind: 'fall-back' }, + { zone: 'Pacific/Auckland', instant: '2026-04-05T12:00:00Z', range: 'last 2 weeks', unit: 'week', num: 2, kind: 'fall-back' }, + { zone: 'Pacific/Chatham', instant: '2026-04-05T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' }, + { zone: 'America/New_York', instant: '2026-01-01T12:00:00Z', range: 'last 1 month', unit: 'month', num: 1, kind: 'fall-back' }, + { zone: 'Europe/London', instant: '2026-01-01T12:00:00Z', range: 'last 3 months', unit: 'month', num: 3, kind: 'fall-back' }, + { zone: 'America/Santiago', instant: '2026-04-06T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'fall-back' }, + { zone: 'Europe/Berlin', instant: '2026-03-30T12:00:00Z', range: 'last 1 year', unit: 'year', num: 1, kind: 'spring-forward' }, +]; + +const label = (c: Cell) => `${c.zone} @ ${c.instant} '${c.range}'`; + +/** Probe instants straddling BOTH candidate boundaries, computed in-zone. */ +function probesFor(c: Cell): string[] { + const truth = Date.parse(utcArithmeticStart(c.unit, c.num)); + const mixed = Date.parse(localArithmeticStart(c.unit, c.num)); + return [...new Set([ + new Date(truth).toISOString(), + new Date(truth - 1).toISOString(), + new Date(mixed).toISOString(), + new Date(mixed - 1).toISOString(), + new Date(truth + 43_200_000).toISOString(), // comfortably inside, both ways + ])].sort(); +} + +describe('#15825 defect 2 — the `last N ...` legs resolve on one calendar, across DST transitions', () => { + for (const c of DST_CELLS) { + it(`${c.kind}: ${label(c)}`, async () => { + const { truth, mixed, probes } = await at(c.zone, c.instant, async () => ({ + truth: utcArithmeticStart(c.unit, c.num), + mixed: localArithmeticStart(c.unit, c.num), + probes: probesFor(c), + })); + + // CONTROL FIRST — if these agree, the cell is not in a transition + // window and every assertion below would be vacuous. (It is the + // whole reason a TZ=UTC-only test is worthless here.) + expect( + mixed, + `${label(c)}: the local-arithmetic spelling must DISAGREE here, otherwise this cell pins nothing`, + ).not.toBe(truth); + + const inZone = await at(c.zone, c.instant, () => probesSelected(probes, c.range)); + const atUtc = await at('UTC', c.instant, () => probesSelected(probes, c.range)); + + // THE ORACLE: at TZ=UTC the two spellings coincide, so this run is + // the reference answer. The process timezone must not move it. + expect(inZone, `${label(c)}: the process timezone changed which rows were counted`).toEqual(atUtc); + + // And the answer must actually be non-trivial — a window that + // selected everything or nothing would compare equal for free. + expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeGreaterThan(0); + expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeLessThan(probes.length); + }); + } + + it('the day and week legs also match the offset-free definition — n x 24h before the UTC day', async () => { + for (const c of DST_CELLS.filter((x) => x.unit === 'day' || x.unit === 'week')) { + await at(c.zone, c.instant, async () => { + const days = c.unit === 'week' ? c.num * 7 : c.num; + expect(utcArithmeticStart(c.unit, c.num), label(c)) + .toBe(new Date(utcBoundary().getTime() - days * 86_400_000).toISOString()); + }); + } + }); + + it('every red cell is live — the local-arithmetic spelling disagrees in all of them', async () => { + const live: string[] = []; + for (const c of DST_CELLS) { + await at(c.zone, c.instant, async () => { + if (localArithmeticStart(c.unit, c.num) !== utcArithmeticStart(c.unit, c.num)) live.push(label(c)); + }); + } + expect(live.length, 'a cell that no longer flips has stopped guarding the fix').toBe(DST_CELLS.length); + }); + + it('both directions are represented — a window start too EARLY and one too LATE', async () => { + const dirs = new Set(); + for (const c of DST_CELLS) { + await at(c.zone, c.instant, async () => { + dirs.add(localArithmeticStart(c.unit, c.num) < utcArithmeticStart(c.unit, c.num) ? 'early' : 'late'); + }); + } + expect([...dirs].sort()).toEqual(['early', 'late']); + }); + + it('all four arithmetic legs are covered — day, week, month and year', () => { + expect([...new Set(DST_CELLS.map((c) => c.unit))].sort()).toEqual(['day', 'month', 'week', 'year']); + }); +}); + +// ── Fences: what this change must NOT have moved ────────────────────────── + +describe('#15825 defect 2 fences', () => { + it('⛔ at TZ=UTC the two spellings are INDISTINGUISHABLE — a UTC-only test proves nothing', async () => { + for (const c of DST_CELLS) { + await at('UTC', c.instant, async () => { + expect(localArithmeticStart(c.unit, c.num), label(c)).toBe(utcArithmeticStart(c.unit, c.num)); + }); + } + }); + + it('zones that do not observe DST are unaffected — both spellings already agreed on the day legs there', async () => { + for (const zone of ['UTC', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Perth']) { + for (const instant of ['2026-03-09T12:00:00Z', '2026-11-02T12:00:00Z', '2026-06-15T12:00:00Z']) { + await at(zone, instant, async () => { + expect(localArithmeticStart('day', 7), `${zone} @ ${instant}`) + .toBe(utcArithmeticStart('day', 7)); + }); + } + } + }); + + it('an ordinary instant in a DST zone is unaffected — the local day is 24h there', async () => { + for (const zone of [...new Set(DST_CELLS.map((c) => c.zone))]) { + await at(zone, '2026-06-15T12:00:00Z', async () => { + expect(localArithmeticStart('day', 3), zone).toBe(utcArithmeticStart('day', 3)); + }); + } + }); + + it('the process timezone is restored after every case', () => { + expect(process.env.TZ).toBe(REAL_TZ); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts new file mode 100644 index 0000000000..ab19b43c84 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15825 — DEFECT 1 of 2: `parseDateRangeString()` built its window BOUNDARY + * on the LOCAL calendar and rendered it as UTC. + * + * ## What this file pins, and what it deliberately does not + * + * `new Date(y, m, d)` constructs LOCAL midnight; `toISOString()` renders that + * instant in UTC. So in any process not sitting at UTC the `'today'` bucket + * was the LOCAL day expressed as a UTC range, shifted by the zone's offset. + * This is wrong on EVERY day of the year — ⛔ not only across a DST + * transition — which is why every cell below is an ORDINARY instant with no + * transition anywhere near it. That is the whole reason defect 1 is the + * cheaper of the two to pin, and why it is pinned first. + * + * Defect 2 (the `last N ...` legs doing LOCAL day/month/year arithmetic) is a + * separate spelling in a separate branch and is pinned in a separate file, + * `memory-analytics-date-range-dst.test.ts`. ⛔ Fixing either one does not fix + * the other: this file goes red against `Date.UTC` reverted even when every + * `setUTCDate` is in place, and its sibling goes red against `setDate` + * restored even when the boundary is `Date.UTC`. Each was ablated separately. + * + * ## Why the assertions are about ROWS + * + * The window is internal; what a user sees is which rows an analytics answer + * counted. The defect earns its priority from a platform DISAGREEMENT — the + * rest of the platform resolves a bare date to the UTC day (`@objectstack/core`'s + * `{today}` macro, `{TODAY()}` in flow templates) — so the same question asked + * through this path and through a flow token selected different rows in one + * deployment. These cases therefore drive the real public entry, + * `MemoryAnalyticsService.query()`, and assert on the row set it returns. + * + * ## The inline control is load-bearing + * + * Each cell also evaluates the OLD spelling directly and asserts it DISAGREES + * with the UTC day. Without it a green run would be ambiguous between "the fix + * works" and "this zone's offset happens to be zero at this instant" — and the + * fences at the bottom show that second state is real: at `TZ=UTC` the two + * spellings are IDENTICAL, which is exactly why nothing in CI ever reddened. + */ + +import { describe, it, expect } from 'vitest'; +import { vi } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import type { 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; + } +} + +/** The defect, spelled out: LOCAL midnight, rendered on the UTC calendar. */ +function localMidnightSpelling(): string { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), n.getDate()).toISOString(); +} + +/** + * The truth, computed from NEITHER spelling: the start of the UTC day the + * frozen instant falls in. `'today'` means "the UTC day it is now", and the + * instant is written in UTC, so this is a slice — not a second implementation. + */ +function utcDayStart(instant: string): string { + return `${instant.slice(0, 10)}T00:00:00.000Z`; +} + +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); + +/** Ask `range` over rows planted at `instants`; answer which probes came back. */ +async function probesSelected(instants: string[], range = 'today'): Promise { + const driver = new InMemoryDriver({ + initialData: { + events: instants.map((iso, i) => ({ + id: i + 1, + probe: iso, + created_at: new Date(iso), + })), + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query(asQuery({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], + })); + return result.rows + .map((row) => String(row['events.probe'])) + .sort(); +} + +interface Cell { + zone: string; + /** Frozen clock, always written in UTC. */ + instant: string; + /** The zone's UTC offset at that instant, for readability only. */ + offset: string; +} + +/** + * Ordinary instants — ⛔ no DST transition is involved in any of them, which + * is the point. Both signs of offset, and three zones whose offset is not a + * whole number of hours (Kolkata +05:30, Chatham +12:45, Kathmandu +05:45) so + * a whole-hour assumption cannot hide in the repair. + */ +const CELLS: Cell[] = [ + { zone: 'Asia/Shanghai', instant: '2026-09-05T12:00:00Z', offset: '+08:00' }, + { zone: 'Asia/Tokyo', instant: '2026-09-05T20:30:00Z', offset: '+09:00' }, + { zone: 'America/Los_Angeles', instant: '2026-09-05T12:00:00Z', offset: '-07:00' }, + { zone: 'America/Los_Angeles', instant: '2026-01-15T12:00:00Z', offset: '-08:00' }, + { zone: 'America/New_York', instant: '2026-06-15T02:00:00Z', offset: '-04:00' }, + { zone: 'Europe/Berlin', instant: '2026-09-05T21:30:00Z', offset: '+02:00' }, + { zone: 'Asia/Kolkata', instant: '2026-09-05T19:00:00Z', offset: '+05:30' }, + { zone: 'Pacific/Chatham', instant: '2026-06-15T12:00:00Z', offset: '+12:45' }, + { zone: 'Asia/Kathmandu', instant: '2026-09-05T19:00:00Z', offset: '+05:45' }, + { zone: 'Pacific/Kiritimati', instant: '2026-09-05T09:00:00Z', offset: '+14:00' }, + { zone: 'Pacific/Niue', instant: '2026-09-05T12:00:00Z', offset: '-11:00' }, +]; + +const label = (c: Cell) => `${c.zone} (${c.offset}) @ ${c.instant}`; + +const MS_DAY = 86_400_000; + +describe("#15825 defect 1 — the 'today' window boundary is the UTC day, in every zone", () => { + for (const c of CELLS) { + it(`${label(c)}: 'today' selects the UTC day, not the local one`, async () => { + await at(c.zone, c.instant, async () => { + const truth = utcDayStart(c.instant); + const buggy = localMidnightSpelling(); + + // CONTROL FIRST — if these agree, this zone has no offset at + // this instant and every assertion below would be vacuous. + expect( + buggy, + `${label(c)}: the local-midnight spelling must DISAGREE with the UTC day, otherwise this cell pins nothing`, + ).not.toBe(truth); + + const truthMs = Date.parse(truth); + const buggyMs = Date.parse(buggy); + + // Probes chosen so that BOTH directions of the shift are + // caught: the row exactly on the UTC boundary (dropped when + // the window starts late) and the row on the local boundary + // (wrongly counted when the window starts early). All sit far + // from the window's upper bound, so nothing here depends on + // whether that bound is open or closed. + const probes = [ + new Date(truthMs).toISOString(), // in — first instant of the UTC day + new Date(truthMs - 1).toISOString(), // out — last instant of the previous UTC day + new Date(truthMs + MS_DAY / 2).toISOString(), // in — midday, unambiguous anchor + new Date(buggyMs).toISOString(), // the local boundary + new Date(buggyMs - 1).toISOString(), + ]; + + const expected = probes + .filter((iso) => { + const t = Date.parse(iso); + return t >= truthMs && t < truthMs + MS_DAY; + }) + .sort(); + + await expect(probesSelected(probes)).resolves.toEqual(expected); + }); + }); + } + + it('every cell is live — the local-midnight spelling disagrees in all of them', async () => { + const live: string[] = []; + for (const c of CELLS) { + await at(c.zone, c.instant, async () => { + if (localMidnightSpelling() !== utcDayStart(c.instant)) live.push(label(c)); + }); + } + expect(live.length, 'a cell that no longer shifts has stopped guarding the fix').toBe(CELLS.length); + }); + + it('both directions are represented — a window starting EARLY and one starting LATE', async () => { + const dirs = new Set(); + for (const c of CELLS) { + await at(c.zone, c.instant, async () => { + dirs.add(localMidnightSpelling() < utcDayStart(c.instant) ? 'early' : 'late'); + }); + } + expect([...dirs].sort()).toEqual(['early', 'late']); + }); +}); + +// ── Fences: what this change must NOT have moved ────────────────────────── + +describe('#15825 defect 1 fences', () => { + it('⛔ at TZ=UTC the two spellings are INDISTINGUISHABLE — a UTC-only test proves nothing', async () => { + for (const instant of ['2026-09-05T12:00:00Z', '2026-01-15T23:59:00Z', '2026-06-15T00:00:00Z']) { + await at('UTC', instant, async () => { + expect(localMidnightSpelling(), instant).toBe(utcDayStart(instant)); + }); + } + }); + + it('an explicit array dateRange is untouched — it never reaches the parser', async () => { + await at('Asia/Shanghai', '2026-09-05T12:00:00Z', async () => { + const driver = new InMemoryDriver({ + initialData: { + events: [ + { id: 1, probe: 'in', created_at: new Date('2026-09-05T00:00:00.000Z') }, + { id: 2, probe: 'out', created_at: new Date('2026-09-07T00:00:00.000Z') }, + ], + }, + }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); + const result = await service.query(asQuery({ + cube: 'events', + measures: ['events.count'], + dimensions: ['events.probe'], + timeDimensions: [{ + dimension: 'events.createdAt', + dateRange: ['2026-09-05', '2026-09-05'], + }], + })); + expect(result.rows.map((r) => r['events.probe'])).toEqual(['in']); + }); + }); + + it('the unrecognised-range fallback carries no calendar — same answer in every zone', async () => { + // This repair touched only the two legs that BUILD a window. The + // `return [range, range]` fallback is untouched, and this fence holds + // it that way: its answer must not depend on the process timezone. + // + // ⚠️ It is deliberately NOT asserted to be a sensible answer. Measured + // 2026-09-05: an unparseable `dateRange` reaches mingo as + // `{$gte: '', $lte: ''}` and, under BSON cross-type + // ordering, matches EVERY `Date`-typed row — so the time filter is + // silently dropped rather than refused. That is a different defect + // class from this card's (vocabulary, not calendar) and is filed + // separately; ⛔ it is not repaired here. + const probes = [ + '2020-01-01T00:00:00.000Z', + '2026-09-05T06:00:00.000Z', + '2099-01-01T00:00:00.000Z', + ]; + const answers: string[] = []; + for (const zone of ['UTC', 'Asia/Shanghai', 'America/Los_Angeles', 'Pacific/Chatham']) { + await at(zone, '2026-09-05T12:00:00Z', async () => { + answers.push(JSON.stringify(await probesSelected(probes, 'not a range at all'))); + }); + } + expect(new Set(answers).size, `the fallback answered differently per zone: ${answers.join(' | ')}`).toBe(1); + }); + + it('the process timezone is restored after every case', () => { + expect(process.env.TZ).toBe(REAL_TZ); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index 8c5d6aada8..6235995887 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -1415,8 +1415,50 @@ export class MemoryAnalyticsService implements IAnalyticsService { private parseDateRangeString(range: string): string[] { // Simple parser for common date range strings // In production, this would use a proper date range parser + // + // [#15825] ONE calendar, and it is UTC -- the same one `toISOString()` + // renders every bound below on. Two INDEPENDENT defects lived here: + // + // 1. The window BOUNDARY was `new Date(y, m, d)` -- LOCAL midnight -- + // rendered as UTC. Wrong on every day of the year in every non-UTC + // process, with no DST transition needed: measured 2026-09-05, the + // `'today'` bucket ran from the previous 16:00Z at `Asia/Shanghai`, + // from 07:00Z at `America/Los_Angeles` (08:00Z outside its DST). + // 2. The `last N ...` legs did their arithmetic on the LOCAL calendar + // (`setDate` / `setMonth` / `setFullYear`) and rendered on the UTC + // one. `setDate` preserves WALL-CLOCK time, so the instant moves + // n x 24h only while every local day in the window is 24 hours + // long; across a DST transition it moves 23h or 25h and the window + // start slips an hour. + // + // ⛔ They do not fix each other: `setUTCDate` alone leaves the + // local-midnight boundary in place, and `Date.UTC` alone leaves the + // arithmetic mixed. Each is pinned by its own file, and each was ablated + // separately to prove it -- `memory-analytics-date-range-utc-window.test.ts` + // (boundary; red in any non-UTC zone, no transition instant needed) and + // `memory-analytics-date-range-dst.test.ts` (arithmetic; CANNOT go red at + // TZ=UTC, where the two spellings are indistinguishable -- which is + // exactly why nothing in CI ever reddened on this). + // + // UTC is the target calendar, not merely "a consistent one". The rest of + // the platform resolves a bare date to the UTC day: `@objectstack/core`'s + // `{today}` filter-token macro builds its reference day as + // `new Date(Date.UTC(year, month - 1, day))` and falls back to UTC parts + // when the context carries no timezone, and `{TODAY()}` in flow templates + // resolves to the UTC day (#14852, same two-calendar shape). So the same + // analytics question asked through this path and through a flow token no + // longer selects different rows in one deployment -- that agreement, not + // the hour count, is what this repair restores. + // + // ⚠️ Out of scope here, filed separately: `AnalyticsQuery.timezone` is + // declared (optional, no default) and this path does not consult it. UTC + // is the terminal fallback of the engine's own resolution chain + // (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), + // so UTC-ising is correct for every query that carries no timezone; + // making the range tokens timezone-AWARE is a larger question, and + // #14852 explicitly declined the same one. const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); if (range === 'today') { return [today.toISOString(), new Date(today.getTime() + 86400000).toISOString()]; @@ -1427,13 +1469,13 @@ export class MemoryAnalyticsService implements IAnalyticsService { const start = new Date(today); if (unit.startsWith('day')) { - start.setDate(start.getDate() - num); + start.setUTCDate(start.getUTCDate() - num); } else if (unit.startsWith('week')) { - start.setDate(start.getDate() - num * 7); + start.setUTCDate(start.getUTCDate() - num * 7); } else if (unit.startsWith('month')) { - start.setMonth(start.getMonth() - num); + start.setUTCMonth(start.getUTCMonth() - num); } else if (unit.startsWith('year')) { - start.setFullYear(start.getFullYear() - num); + start.setUTCFullYear(start.getUTCFullYear() - num); } return [start.toISOString(), now.toISOString()];