Skip to content

Commit bc4c963

Browse files
fix(driver-memory): a preset dateRange on a date field counts the reference zone's calendar days, from UTC westward too (#27341)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_ Fixes #27272 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:内存驱动的分析查询里,预设日期范围(如“本季度”)落在声明为 `date` 的字段上时,改为按参考时区的日历日计数;`datetime` 字段仍按时刻比较。这条窗口规则从 service-analytics 挪到 `@objectstack/core`,两边共用同一份。 - 为什么改:从 UTC 往西(洛杉矶,连 UTC 本身也是),“本季度”会漏掉 7 月 1 日、多算 10 月 1 日,报表数字静默出错;上海只是碰巧算对。 - 风险与回滚:只影响“预设范围 × 已声明为 date 的字段”,在内存驱动上;显式起止日期、datetime 字段、未声明类型的字段行为不变,service-analytics 行为不变(只是改为调用 core)。回滚即还原本 PR。 - 先看:`packages/core/src/utils/analytics-date-range.ts` 末尾新增的两个导出;`packages/drivers/driver-memory/src/memory-analytics.ts` 的 `parseDateRangeString`;`packages/services/service-analytics/src/dataset-executor.ts` 删掉的旧副本。 - 两分钟验证:`pnpm --filter @objectstack/driver-memory exec vitest run src/memory-analytics-date-range-date-field.test.ts`,预期 7 条全过,其中洛杉矶、UTC、上海的 this_quarter 都只列 7/1 和 9/30。 - 需要你做的:无,席位落地。 ## What changes **The rule has one home.** ADR-0053's window rule (a `date` column compares the calendar days the period covers in the reference zone; a `datetime` column compares instants) was spelled once, in `service-analytics/src/dataset-executor.ts` (`dateRangeWindowBounds` over the private `inclusiveCalendarDayWindow`, PR #27271). `@objectstack/driver-memory` depends only on `core`, `spec` and `types`, so it cannot import that. Both functions move, unchanged in behaviour, into `@objectstack/core`'s `utils/analytics-date-range.ts`, beside `resolveAnalyticsDateRangeString`, whose header already says the lowering lives there because these two faces cannot import each other. `dataset-executor.ts` imports both from core and re-exports `dateRangeWindowBounds`, so the four service-analytics importers keep their import line. Nothing in `packages/spec` moves. **The memory face uses it.** `MemoryAnalyticsService` asks the driver whether the time dimension's field is declared `date`. If it is, a preset resolves through `dateRangeWindowBounds(window, timezone, true)` to the inclusive day pair, and the existing bound construction reads that pair exactly as it reads a caller's bare-day `[a, b]` (`$gte` first day, `$lt` the next day after the last). Every other field keeps the instant window it had. **One driver accessor.** The declared kind lives in `InMemoryDriver`'s private `temporalFields` (filled by `syncSchema`), so the driver gains `isDeclaredDateField(object, field)`, the same pattern as `filterComparandStorageForm`. An object never declared answers `false`: the driver does not guess types from values. **Surface widening.** The order's file surface was `memory-analytics.ts`, its test, the rule's home and `dataset-executor.ts`. It grew by `packages/drivers/driver-memory/src/memory-driver.ts` (the accessor only) and `packages/core/src/utils/analytics-date-range.ts` (+ its test), on the seat's call (option A; option B, inferring the kind by probing a comparand's stored shape, was refused as a type guess from a value). `Clause-②: yes (widening)`: `@objectstack/core` gains the exports `dateRangeWindowBounds` and `inclusiveCalendarDayWindow`, and `InMemoryDriver` gains the public method `isDeclaredDateField`. Nothing is removed or narrowed. One changeset, `patch` for `core`, `driver-memory` and `service-analytics`. ## Tests - New `packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts` (7): the card's rows (06-30, 07-01, 09-30, 10-01), clock frozen at 2026-09-09, declared via `syncSchema`. `this_quarter` in `America/Los_Angeles`, `UTC` and `Asia/Shanghai` selects 07-01 and 09-30; Los Angeles `this_month` selects 09-30 and not 10-01; an explicit `['2026-07-01', '2026-09-30']` is unchanged. A declared `datetime` field keeps the zone-midnight instant pair (both edges pinned one second either side). An undeclared field keeps the instant pair. - `packages/core/src/utils/analytics-date-range.test.ts` (+5): the rule itself, three zones, the non-date arm and the explicit arm. <details><summary>Red before the fix (origin/main ebfadb0)</summary> ``` × America/Los_Angeles: this_quarter selects Jul 1 through Sep 30 × UTC: this_quarter selects Jul 1 through Sep 30 × Los Angeles: this_month on Sep 9 selects Sep 30 and not Oct 1 AssertionError: expected [ '2026-09-30', '2026-10-01' ] to deeply equal [ '2026-07-01', '2026-09-30' ] Tests 3 failed | 4 passed (7) ``` Shanghai and the datetime and undeclared pins were green before and after. UTC is red too, not only zones west of it: the bare day `2026-07-01` sorts before `2026-07-01T00:00:00.000Z` as text. </details> <details><summary>Ablation (fix committed first, then the face's date branch disabled through scripts/ablation-replace.mjs)</summary> ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a0d8cde -> c3b278652e1c × America/Los_Angeles: this_quarter selects Jul 1 through Sep 30 × UTC: this_quarter selects Jul 1 through Sep 30 × Los Angeles: this_month on Sep 9 selects Sep 30 and not Oct 1 Tests 3 failed | 4 passed (7) ablation-replace: blob after restore a0d8cde ``` A first attempt used a marker that threw a ReferenceError (red for the wrong reason) and is discarded; the run above is the one that counts. `git diff HEAD` was empty after the restore. </details> <details><summary>Package suites (HEAD 4ddf1d4)</summary> ``` @objectstack/core test + typecheck: Test Files 124 passed, Tests 2808 passed @objectstack/driver-memory test + typecheck: Test Files 74 passed, Tests 1767 passed @objectstack/service-analytics test + typecheck: Test Files 195 passed, Tests 4705 passed | 282 skipped eslint --no-inline-config on the six touched source files: exit 0 ``` </details> ## Acceptance notes - **Deviation, for triage to rule on if it disagrees: an undeclared field keeps the instant window.** The card measured the undeclared case wrong as well. This PR follows `dateRangeWindowBounds`' own contract from #27172 / PR #27271, "a column whose type the caller cannot name answers `false` and keeps the instants", and this driver's rule that it does not guess types from values (`memory-temporal.ts`). So a bare day stored in a field the driver was never told is `date` is still compared to the preset's instants as text. Making it count calendar days would need the face to read the type from the stored value, which is the option the seat refused. - The face's `$or` over both stored forms is unchanged; on a declared `date` field the `Date` arm reads the day pair as UTC midnights, as it does for a caller's bare-day window. - No behaviour change in service-analytics: its four call sites resolve the same function, now from core. - The branch carries two early WIP commits (`7cd672d6`, `46faf53d`) committed without the session trailer pair; the final commit `4ddf1d4a` carries it. The branch name is not `claude/issue-*`, so it is not force-pushed and history is not rewritten. ## Verification Local subset, measured at `4ddf1d4a`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 64 families from the 7 changed paths; each ran from the worktree with its exit code captured before any pipe. <details><summary>dispatch-gates --ran: 64 derived, 63 run, 1 NOT-MEASURED, 0 UNRUN</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-conformance :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:kernel-hook-pairs :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` - `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it needs every workspace package built, and this worktree built only the three packages' closure). NOT MEASURED locally; CI runs it. - `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone; after `git fetch origin 621a487` it exits 0 (48 cases). </details> Every other family CI runs is read from this PR's check-runs, not re-run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbomWvWL9K2F1XBS6p)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 79d5dac commit bc4c963

7 files changed

Lines changed: 313 additions & 85 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/driver-memory': patch
3+
'@objectstack/core': patch
4+
'@objectstack/service-analytics': patch
5+
---
6+
7+
fix(driver-memory): a preset `dateRange` on a `date` field counts the reference time zone's calendar days on the in-memory analytics face
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
An analytics query on the in-memory driver with a preset `dateRange` (`this_quarter`, `this_month`, …) on a dimension whose field is declared `type: 'date'` counted the wrong calendar days from UTC westward. The face compared the preset's resolved instants (the zone's midnight, `2026-07-01T07:00:00.000Z` in `America/Los_Angeles`) against the stored bare day as text, so `this_quarter` (Q3 2026) dropped 2026-07-01 and counted 2026-10-01, in `America/Los_Angeles` and in `UTC` alike.
13+
14+
Now a `date` field compares the calendar days the period covers in the reference zone (Q3 is 2026-07-01 through 2026-09-30 in any zone), and a `datetime` field keeps comparing instants: the same rule service-analytics' faces apply. An explicit `[a, b]` window is unchanged, and so is a field whose type the driver was never told through `syncSchema`.
15+
16+
The rule now has one home, `@objectstack/core`: `dateRangeWindowBounds` and `inclusiveCalendarDayWindow` are exported from it, and `@objectstack/service-analytics` calls them instead of its own copy (no behaviour change there). `InMemoryDriver` gains `isDeclaredDateField(object, field)`, which answers from the declared schema.

‎packages/core/src/utils/analytics-date-range.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
resolveAnalyticsDateRangePreset,
4343
resolveAnalyticsDateRangeString,
4444
analyticsDateRangeUnrecognizedError,
45+
dateRangeWindowBounds,
4546
} from './analytics-date-range.js';
4647

4748
/** A frozen reference instant, deliberately mid-week, mid-month, mid-quarter. */
@@ -369,3 +370,27 @@ describe('#17341 — the END of every window is one CALENDAR day after the presc
369370
expect(end - lastDayStart).not.toBe(24 * 60 * 60 * 1000);
370371
});
371372
});
373+
374+
describe('dateRangeWindowBounds — ADR-0053\'s one window rule (#27172, #27272)', () => {
375+
const now = new Date('2026-09-09T12:00:00Z');
376+
377+
for (const timezone of ['America/Los_Angeles', 'UTC', 'Asia/Shanghai']) {
378+
it(`${timezone}: this_quarter on a date column is Jul 1 through Sep 30`, () => {
379+
const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone });
380+
expect(dateRangeWindowBounds(window, timezone, true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' });
381+
});
382+
}
383+
384+
it('a preset on any other column keeps its instants and stops before its end', () => {
385+
const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone: 'America/Los_Angeles' });
386+
expect(dateRangeWindowBounds(window, 'America/Los_Angeles', false)).toEqual({
387+
$gte: '2026-07-01T07:00:00.000Z',
388+
$lt: '2026-10-01T07:00:00.000Z',
389+
});
390+
});
391+
392+
it('a caller\'s explicit window is handed back bound for bound, inclusive', () => {
393+
const window = { start: '2026-07-01', end: '2026-09-30', endExclusive: false };
394+
expect(dateRangeWindowBounds(window, 'America/Los_Angeles', true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' });
395+
});
396+
});

‎packages/core/src/utils/analytics-date-range.ts‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ import {
8383
analyticsDateRangeRefusalMessage,
8484
type DateRangePreset,
8585
} from '@objectstack/spec/data';
86-
import { zonedDateStartToUtcMs } from './datetime.js';
86+
import type { LoweredDateRangeWindow } from './analytics-date-range-conformance.js';
87+
import { bucketDateKey, zonedDateStartToUtcMs } from './datetime.js';
8788
import { resolveFilterToken } from './filter-tokens.js';
8889

8990
/**
@@ -280,3 +281,90 @@ export function resolveAnalyticsDateRangeString(
280281
if (!isDateRangePresetName(range)) throw analyticsDateRangeUnrecognizedError(range);
281282
return resolveAnalyticsDateRangePreset(range, options);
282283
}
284+
285+
/**
286+
* [#17973] The calendar days a resolved window covers in `timezone`, as the
287+
* INCLUSIVE `[first, last]` pair.
288+
*
289+
* ⚠️ `endExclusive` is load-bearing: every preset reports the first instant
290+
* AFTER the window (`this_month` ends at the next month's start), so the last
291+
* day it covers is the one holding the instant before that bound. Reading that
292+
* bound as inclusive makes every preset one day too long.
293+
*
294+
* [#18245] ⭐ `timezone` decides WHICH calendar. The bounds are INSTANTS, and
295+
* every preset opens and closes at the reference zone's midnight
296+
* ({@link tokenDayStart}), so reading them on the UTC calendar moves a boundary
297+
* in opposite directions either side of the meridian. ⛔ That is a day-boundary
298+
* projection, not an off-by-one: no constant makes both sides right.
299+
*
300+
* A bare `YYYY-MM-DD` bound is a calendar day already and is carried on the
301+
* UTC proxy {@link zonedDateStartToUtcMs} yields for an unset zone.
302+
*
303+
* [#27272] Here, beside the lowering, for the reason the lowering is: the
304+
* analytics faces of `driver-memory` and `@objectstack/service-analytics` both
305+
* filter by it and cannot import each other.
306+
*/
307+
export function inclusiveCalendarDayWindow(
308+
window: LoweredDateRangeWindow,
309+
timezone?: string,
310+
): [string, string] {
311+
const endMs = windowBoundMs(window.end);
312+
return [
313+
calendarDayOf(windowBoundMs(window.start), timezone),
314+
calendarDayOf(window.endExclusive ? endMs - 1 : endMs, timezone),
315+
];
316+
}
317+
318+
/**
319+
* [#27172] The bound pair a lowered `dateRange` window filters one column by —
320+
* the ONE spelling every analytics face shares (#27272: `driver-memory`'s cube
321+
* face, and service-analytics' two strategies, drill and draft preview).
322+
*
323+
* ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date`
324+
* column compares CALENDAR DAYS in the reference zone.
325+
*
326+
* - A resolved preset (`endExclusive`) on a `date` column is the calendar days
327+
* it covers in `timezone` ({@link inclusiveCalendarDayWindow}), as the
328+
* inclusive `{ $gte, $lte }` day pair. Q3 is 2026-07-01 through 2026-09-30
329+
* in any zone.
330+
* - A resolved preset on any other column stays the instant pair it resolved
331+
* to, stopping before its end: `{ $gte, $lt }`.
332+
* - A caller's explicit `[a, b]` is handed back bound for bound, with its
333+
* inclusive upper reading (#16179); each face gives a bare-day end its
334+
* whole-day reading where the column needs one.
335+
*
336+
* ⛔ The preset's instants used to reach a `date` column as they are. A SQL
337+
* `date` column reads an instant by its leading UTC day, so east of UTC the
338+
* window slid one day early (#27172); `driver-memory` compared the instant to
339+
* the stored bare day as TEXT, so from UTC westward it dropped the first day
340+
* and counted the day after the last (#27272).
341+
*
342+
* @param dateColumn - Is the window's column declared `date`? A column whose
343+
* type the caller cannot name answers `false` and keeps the instants.
344+
*/
345+
export function dateRangeWindowBounds(
346+
window: LoweredDateRangeWindow,
347+
timezone: string | undefined,
348+
dateColumn: boolean,
349+
): Record<string, string> {
350+
if (!window.endExclusive) return { $gte: window.start, $lte: window.end };
351+
if (!dateColumn) return { $gte: window.start, $lt: window.end };
352+
const [first, last] = inclusiveCalendarDayWindow(window, timezone);
353+
return { $gte: first, $lte: last };
354+
}
355+
356+
/** The epoch ms one window bound denotes: a bare day on its UTC proxy, else the instant. */
357+
function windowBoundMs(bound: string): number {
358+
const ms = bound.length === 10 ? zonedDateStartToUtcMs(bound) : Date.parse(bound);
359+
if (Number.isNaN(ms)) {
360+
throw new TypeError(`[analytics-date-range] invalid date in a dateRange window: "${bound}"`);
361+
}
362+
return ms;
363+
}
364+
365+
/** The calendar day the instant at `ms` falls on in `timezone`: {@link bucketDateKey} at `'day'`. */
366+
function calendarDayOf(ms: number, timezone?: string): string {
367+
// `ms` is always a finite epoch {@link windowBoundMs} just checked, so the
368+
// labeller answers a key; `String` only narrows its `null` arm away.
369+
return String(bucketDateKey(ms, 'day', timezone));
370+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #27272 — a preset `dateRange` on a `date` field counts the calendar days the
5+
* period covers in the reference zone, on this driver's cube face too.
6+
*
7+
* ADR-0053's one window rule: a `date` field compares CALENDAR DAYS and a
8+
* `datetime` field compares INSTANTS. Q3 is 2026-07-01 through 2026-09-30 in
9+
* any zone. The rule is `@objectstack/core`'s `dateRangeWindowBounds`, the one
10+
* spelling service-analytics' faces share (#27172).
11+
*
12+
* ⛔ Before the repair the face compared the preset's resolved instants (the
13+
* zone's midnight, `2026-07-01T07:00:00.000Z` in Los Angeles) against the
14+
* stored bare day as TEXT. West of UTC `'2026-07-01' < '2026-07-01T07:…'`, so
15+
* `this_quarter` dropped Jul 1 and counted Oct 1. East of UTC the same text
16+
* order happened to land on the right days.
17+
*
18+
* The declaration decides, as everywhere in this driver: a field declared
19+
* `date` through `syncSchema` takes the calendar-day rule; a field whose type
20+
* the driver was never told keeps the instant pair (`dateColumn: false`), as
21+
* `dateRangeWindowBounds` does for a column whose type the caller cannot name.
22+
*/
23+
24+
import { afterEach, describe, it, expect, vi } from 'vitest';
25+
import { InMemoryDriver } from './memory-driver.js';
26+
import { MemoryAnalyticsService } from './memory-analytics.js';
27+
import { AnalyticsQuerySchema } from '@objectstack/spec/data';
28+
import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data';
29+
30+
const CUBE: Cube = {
31+
name: 'deals',
32+
title: 'Deals',
33+
sql: 'deals',
34+
measures: {
35+
count: { label: 'Count', type: 'count', sql: 'id' },
36+
},
37+
dimensions: {
38+
probe: { label: 'Probe', type: 'string', sql: 'probe' },
39+
closeDate: { label: 'Close Date', type: 'time', sql: 'close_date' },
40+
closedAt: { label: 'Closed At', type: 'time', sql: 'closed_at' },
41+
},
42+
public: true,
43+
};
44+
45+
/** The card's four rows: one day either side of Q3, and Q3's own first and last day. */
46+
const DAYS = ['2026-06-30', '2026-07-01', '2026-09-30', '2026-10-01'];
47+
const Q3 = ['2026-07-01', '2026-09-30'];
48+
49+
afterEach(() => {
50+
vi.useRealTimers();
51+
});
52+
53+
async function serviceOver(declare: boolean, rows: Record<string, unknown>[]): Promise<MemoryAnalyticsService> {
54+
const driver = new InMemoryDriver({ initialData: { deals: rows } });
55+
await driver.connect();
56+
if (declare) {
57+
await driver.syncSchema('deals', {
58+
name: 'deals',
59+
fields: {
60+
probe: { type: 'text' },
61+
close_date: { type: 'date' },
62+
closed_at: { type: 'datetime' },
63+
},
64+
});
65+
}
66+
return new MemoryAnalyticsService({ driver, cubes: [CUBE] });
67+
}
68+
69+
async function probes(
70+
service: MemoryAnalyticsService,
71+
dimension: 'deals.closeDate' | 'deals.closedAt',
72+
timezone: string,
73+
dateRange: AnalyticsDateRange = 'this_quarter',
74+
): Promise<string[]> {
75+
vi.useFakeTimers({ toFake: ['Date'] });
76+
vi.setSystemTime(new Date('2026-09-09T12:00:00Z'));
77+
const query: AnalyticsQuery = AnalyticsQuerySchema.parse({
78+
cube: 'deals',
79+
measures: ['deals.count'],
80+
dimensions: ['deals.probe'],
81+
timeDimensions: [{ dimension, dateRange }],
82+
timezone,
83+
});
84+
const result = await service.query(query);
85+
return result.rows.map((row) => String(row['deals.probe'])).sort();
86+
}
87+
88+
describe('#27272 — a preset dateRange on a declared date field counts calendar days', () => {
89+
const dateRows = DAYS.map((day, i) => ({ id: i + 1, probe: day, close_date: day }));
90+
91+
for (const zone of ['America/Los_Angeles', 'Asia/Shanghai', 'UTC']) {
92+
it(`${zone}: this_quarter selects Jul 1 through Sep 30`, async () => {
93+
const service = await serviceOver(true, dateRows);
94+
expect(await probes(service, 'deals.closeDate', zone)).toEqual(Q3);
95+
});
96+
}
97+
98+
it('Los Angeles: this_month on Sep 9 selects Sep 30 and not Oct 1', async () => {
99+
const service = await serviceOver(true, dateRows);
100+
expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', 'this_month')).toEqual(['2026-09-30']);
101+
});
102+
103+
it('an explicit window keeps its inclusive bare-day reading, untouched', async () => {
104+
const service = await serviceOver(true, dateRows);
105+
expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', ['2026-07-01', '2026-09-30'])).toEqual(Q3);
106+
});
107+
});
108+
109+
describe('#27272 — a datetime field keeps comparing instants', () => {
110+
// Los Angeles' Q3 opens at 2026-07-01T07:00Z and closes before 2026-10-01T07:00Z.
111+
const instants = [
112+
'2026-07-01T06:59:59.000Z', // Jun 30, 23:59:59 local — outside
113+
'2026-07-01T07:00:00.000Z', // Jul 1, 00:00 local — inside
114+
'2026-10-01T06:59:59.000Z', // Sep 30, 23:59:59 local — inside
115+
'2026-10-01T07:00:00.000Z', // Oct 1, 00:00 local — outside
116+
];
117+
const rows = instants.map((iso, i) => ({ id: i + 1, probe: iso, closed_at: iso }));
118+
119+
it('Los Angeles: this_quarter on a declared datetime is the zone-midnight instant pair', async () => {
120+
const service = await serviceOver(true, rows);
121+
expect(await probes(service, 'deals.closedAt', 'America/Los_Angeles')).toEqual([
122+
'2026-07-01T07:00:00.000Z',
123+
'2026-10-01T06:59:59.000Z',
124+
]);
125+
});
126+
});
127+
128+
describe('#27272 — a field whose type was never declared keeps the instant pair', () => {
129+
it('Los Angeles: an undeclared column is compared as the instants the preset resolved to', async () => {
130+
const rows = ['2026-07-01T06:59:59.000Z', '2026-07-01T07:00:00.000Z'].map((iso, i) => ({
131+
id: i + 1,
132+
probe: iso,
133+
close_date: iso,
134+
}));
135+
const service = await serviceOver(false, rows);
136+
expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles')).toEqual(['2026-07-01T07:00:00.000Z']);
137+
});
138+
});

‎packages/drivers/driver-memory/src/memory-analytics.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import {
3030
// the ONE refusal for a string outside it, shared with the SQL analytics
3131
// path so the two backends cannot answer one input differently again.
3232
resolveAnalyticsDateRangeString,
33+
// [#27272] ADR-0053's one window rule — a `date` column compares the
34+
// calendar days the period covers in the reference zone — shared with
35+
// service-analytics' faces (#27172) for the same reason as the lowering.
36+
dateRangeWindowBounds,
3337
// [#17596] The ONE constructor for that envelope, reused for the ARRAY arm's
3438
// own refusal: the shared conformance kit reads `code` + `status`, so the
3539
// pair must have a single origin across every face. ⛔ Only the SENTENCE is
@@ -1196,7 +1200,11 @@ export class MemoryAnalyticsService implements IAnalyticsService {
11961200
// the aggregation with NO time predicate emitted at all.
11971201
const resolved: ResolvedDateRange = Array.isArray(timeDim.dateRange)
11981202
? { bounds: explicitDateRangeWindow(timeDim.dateRange), endExclusive: false }
1199-
: this.parseDateRangeString(timeDim.dateRange, query.timezone);
1203+
: this.parseDateRangeString(
1204+
timeDim.dateRange,
1205+
query.timezone,
1206+
this.driver.isDeclaredDateField(this.extractTableName(cube.sql), fieldPath),
1207+
);
12001208
const range = resolved.bounds;
12011209

12021210
// The window matches BOTH stored forms of a datetime value — the
@@ -2243,13 +2251,26 @@ export class MemoryAnalyticsService implements IAnalyticsService {
22432251
* explicit `[a, b]` array arm never arrives here at all: it is discriminated
22442252
* at the call site and keeps its published `$lte` reading.
22452253
*
2254+
* [#27272] On a column the driver holds as declared `date` (`dateColumn`)
2255+
* the window is instead the calendar days it covers, with an inclusive end.
2256+
*
22462257
* @throws the ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope for
22472258
* a string outside `DATE_RANGE_PRESETS` — the same code, status and
22482259
* wording the schema door and the SQL analytics path answer with, since
22492260
* all three call one constructor.
22502261
*/
2251-
private parseDateRangeString(range: string, timezone?: string): ResolvedDateRange {
2262+
private parseDateRangeString(range: string, timezone: string | undefined, dateColumn: boolean): ResolvedDateRange {
22522263
const window = resolveAnalyticsDateRangeString(range, { timezone });
2264+
if (dateColumn) {
2265+
// [#27272] A declared `date` column holds bare days, and the preset's
2266+
// instants (the zone's midnight) compared to them as TEXT dropped the
2267+
// first day and counted the day after the last from UTC westward. ADR-0053's
2268+
// one rule, through its one spelling: the calendar days the period covers
2269+
// in the reference zone, as the inclusive day pair — which the bound
2270+
// construction then reads exactly as a caller's bare-day `[a, b]`.
2271+
const days = dateRangeWindowBounds(window, timezone, true);
2272+
return { bounds: [days.$gte, days.$lte], endExclusive: false };
2273+
}
22532274
return { bounds: [window.start, window.end], endExclusive: window.endExclusive };
22542275
}
22552276

‎packages/drivers/driver-memory/src/memory-driver.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2359,6 +2359,20 @@ export class InMemoryDriver implements IDataDriver {
23592359
return this.toStorageForm(object, field, value);
23602360
}
23612361

2362+
/**
2363+
* [#27272] Is `field` on `object` declared `date`? For the analytics (cube)
2364+
* face's `dateRange` window, which compares a `date` column by CALENDAR DAYS
2365+
* and every other column by instants (ADR-0053, `dateRangeWindowBounds`).
2366+
*
2367+
* Keyed on the DECLARED kind ({@link temporalFields}, populated by
2368+
* {@link syncSchema}) for the reason {@link filterComparandStorageForm} is:
2369+
* only the driver holds it. An object never declared answers `false` — the
2370+
* driver does not guess types from values.
2371+
*/
2372+
isDeclaredDateField(object: string | undefined, field: string): boolean {
2373+
return this.temporalKind(object, field) === 'date';
2374+
}
2375+
23622376
/**
23632377
* [#5374] The pattern a `$contains` / `$notContains` comparand becomes — the
23642378
* substring rule itself, for the analytics (cube) face.

0 commit comments

Comments
 (0)