diff --git a/.changeset/27272-memory-preset-date-field-calendar-days.md b/.changeset/27272-memory-preset-date-field-calendar-days.md new file mode 100644 index 00000000000..e595b132a8b --- /dev/null +++ b/.changeset/27272-memory-preset-date-field-calendar-days.md @@ -0,0 +1,16 @@ +--- +'@objectstack/driver-memory': patch +'@objectstack/core': patch +'@objectstack/service-analytics': patch +--- + +fix(driver-memory): a preset `dateRange` on a `date` field counts the reference time zone's calendar days on the in-memory analytics face + +Clause-②: yes (widening) +Metadata-protocol: none + +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. + +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`. + +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. diff --git a/packages/core/src/utils/analytics-date-range.test.ts b/packages/core/src/utils/analytics-date-range.test.ts index 2c58fec5929..c8fbaec8403 100644 --- a/packages/core/src/utils/analytics-date-range.test.ts +++ b/packages/core/src/utils/analytics-date-range.test.ts @@ -42,6 +42,7 @@ import { resolveAnalyticsDateRangePreset, resolveAnalyticsDateRangeString, analyticsDateRangeUnrecognizedError, + dateRangeWindowBounds, } from './analytics-date-range.js'; /** 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 expect(end - lastDayStart).not.toBe(24 * 60 * 60 * 1000); }); }); + +describe('dateRangeWindowBounds — ADR-0053\'s one window rule (#27172, #27272)', () => { + const now = new Date('2026-09-09T12:00:00Z'); + + for (const timezone of ['America/Los_Angeles', 'UTC', 'Asia/Shanghai']) { + it(`${timezone}: this_quarter on a date column is Jul 1 through Sep 30`, () => { + const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone }); + expect(dateRangeWindowBounds(window, timezone, true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' }); + }); + } + + it('a preset on any other column keeps its instants and stops before its end', () => { + const window = resolveAnalyticsDateRangePreset('this_quarter', { now, timezone: 'America/Los_Angeles' }); + expect(dateRangeWindowBounds(window, 'America/Los_Angeles', false)).toEqual({ + $gte: '2026-07-01T07:00:00.000Z', + $lt: '2026-10-01T07:00:00.000Z', + }); + }); + + it('a caller\'s explicit window is handed back bound for bound, inclusive', () => { + const window = { start: '2026-07-01', end: '2026-09-30', endExclusive: false }; + expect(dateRangeWindowBounds(window, 'America/Los_Angeles', true)).toEqual({ $gte: '2026-07-01', $lte: '2026-09-30' }); + }); +}); diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 09fc649fc7f..7901ad9f33b 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -83,7 +83,8 @@ import { analyticsDateRangeRefusalMessage, type DateRangePreset, } from '@objectstack/spec/data'; -import { zonedDateStartToUtcMs } from './datetime.js'; +import type { LoweredDateRangeWindow } from './analytics-date-range-conformance.js'; +import { bucketDateKey, zonedDateStartToUtcMs } from './datetime.js'; import { resolveFilterToken } from './filter-tokens.js'; /** @@ -280,3 +281,90 @@ export function resolveAnalyticsDateRangeString( if (!isDateRangePresetName(range)) throw analyticsDateRangeUnrecognizedError(range); return resolveAnalyticsDateRangePreset(range, options); } + +/** + * [#17973] The calendar days a resolved window covers in `timezone`, as the + * INCLUSIVE `[first, last]` pair. + * + * ⚠️ `endExclusive` is load-bearing: every preset reports the first instant + * AFTER the window (`this_month` ends at the next month's start), so the last + * day it covers is the one holding the instant before that bound. Reading that + * bound as inclusive makes every preset one day too long. + * + * [#18245] ⭐ `timezone` decides WHICH calendar. The bounds are INSTANTS, and + * every preset opens and closes at the reference zone's midnight + * ({@link tokenDayStart}), so reading them on the UTC calendar moves a boundary + * in opposite directions either side of the meridian. ⛔ That is a day-boundary + * projection, not an off-by-one: no constant makes both sides right. + * + * A bare `YYYY-MM-DD` bound is a calendar day already and is carried on the + * UTC proxy {@link zonedDateStartToUtcMs} yields for an unset zone. + * + * [#27272] Here, beside the lowering, for the reason the lowering is: the + * analytics faces of `driver-memory` and `@objectstack/service-analytics` both + * filter by it and cannot import each other. + */ +export function inclusiveCalendarDayWindow( + window: LoweredDateRangeWindow, + timezone?: string, +): [string, string] { + const endMs = windowBoundMs(window.end); + return [ + calendarDayOf(windowBoundMs(window.start), timezone), + calendarDayOf(window.endExclusive ? endMs - 1 : endMs, timezone), + ]; +} + +/** + * [#27172] The bound pair a lowered `dateRange` window filters one column by — + * the ONE spelling every analytics face shares (#27272: `driver-memory`'s cube + * face, and service-analytics' two strategies, drill and draft preview). + * + * ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date` + * column compares CALENDAR DAYS in the reference zone. + * + * - A resolved preset (`endExclusive`) on a `date` column is the calendar days + * it covers in `timezone` ({@link inclusiveCalendarDayWindow}), as the + * inclusive `{ $gte, $lte }` day pair. Q3 is 2026-07-01 through 2026-09-30 + * in any zone. + * - A resolved preset on any other column stays the instant pair it resolved + * to, stopping before its end: `{ $gte, $lt }`. + * - A caller's explicit `[a, b]` is handed back bound for bound, with its + * inclusive upper reading (#16179); each face gives a bare-day end its + * whole-day reading where the column needs one. + * + * ⛔ The preset's instants used to reach a `date` column as they are. A SQL + * `date` column reads an instant by its leading UTC day, so east of UTC the + * window slid one day early (#27172); `driver-memory` compared the instant to + * the stored bare day as TEXT, so from UTC westward it dropped the first day + * and counted the day after the last (#27272). + * + * @param dateColumn - Is the window's column declared `date`? A column whose + * type the caller cannot name answers `false` and keeps the instants. + */ +export function dateRangeWindowBounds( + window: LoweredDateRangeWindow, + timezone: string | undefined, + dateColumn: boolean, +): Record { + if (!window.endExclusive) return { $gte: window.start, $lte: window.end }; + if (!dateColumn) return { $gte: window.start, $lt: window.end }; + const [first, last] = inclusiveCalendarDayWindow(window, timezone); + return { $gte: first, $lte: last }; +} + +/** The epoch ms one window bound denotes: a bare day on its UTC proxy, else the instant. */ +function windowBoundMs(bound: string): number { + const ms = bound.length === 10 ? zonedDateStartToUtcMs(bound) : Date.parse(bound); + if (Number.isNaN(ms)) { + throw new TypeError(`[analytics-date-range] invalid date in a dateRange window: "${bound}"`); + } + return ms; +} + +/** The calendar day the instant at `ms` falls on in `timezone`: {@link bucketDateKey} at `'day'`. */ +function calendarDayOf(ms: number, timezone?: string): string { + // `ms` is always a finite epoch {@link windowBoundMs} just checked, so the + // labeller answers a key; `String` only narrows its `null` arm away. + return String(bucketDateKey(ms, 'day', timezone)); +} diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts new file mode 100644 index 00000000000..13653130089 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-date-field.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27272 — a preset `dateRange` on a `date` field counts the calendar days the + * period covers in the reference zone, on this driver's cube face too. + * + * ADR-0053's one window rule: a `date` field compares CALENDAR DAYS and a + * `datetime` field compares INSTANTS. Q3 is 2026-07-01 through 2026-09-30 in + * any zone. The rule is `@objectstack/core`'s `dateRangeWindowBounds`, the one + * spelling service-analytics' faces share (#27172). + * + * ⛔ Before the repair the face compared the preset's resolved instants (the + * zone's midnight, `2026-07-01T07:00:00.000Z` in Los Angeles) against the + * stored bare day as TEXT. West of UTC `'2026-07-01' < '2026-07-01T07:…'`, so + * `this_quarter` dropped Jul 1 and counted Oct 1. East of UTC the same text + * order happened to land on the right days. + * + * The declaration decides, as everywhere in this driver: a field declared + * `date` through `syncSchema` takes the calendar-day rule; a field whose type + * the driver was never told keeps the instant pair (`dateColumn: false`), as + * `dateRangeWindowBounds` does for a column whose type the caller cannot name. + */ + +import { afterEach, 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 CUBE: Cube = { + name: 'deals', + title: 'Deals', + sql: 'deals', + measures: { + count: { label: 'Count', type: 'count', sql: 'id' }, + }, + dimensions: { + probe: { label: 'Probe', type: 'string', sql: 'probe' }, + closeDate: { label: 'Close Date', type: 'time', sql: 'close_date' }, + closedAt: { label: 'Closed At', type: 'time', sql: 'closed_at' }, + }, + public: true, +}; + +/** The card's four rows: one day either side of Q3, and Q3's own first and last day. */ +const DAYS = ['2026-06-30', '2026-07-01', '2026-09-30', '2026-10-01']; +const Q3 = ['2026-07-01', '2026-09-30']; + +afterEach(() => { + vi.useRealTimers(); +}); + +async function serviceOver(declare: boolean, rows: Record[]): Promise { + const driver = new InMemoryDriver({ initialData: { deals: rows } }); + await driver.connect(); + if (declare) { + await driver.syncSchema('deals', { + name: 'deals', + fields: { + probe: { type: 'text' }, + close_date: { type: 'date' }, + closed_at: { type: 'datetime' }, + }, + }); + } + return new MemoryAnalyticsService({ driver, cubes: [CUBE] }); +} + +async function probes( + service: MemoryAnalyticsService, + dimension: 'deals.closeDate' | 'deals.closedAt', + timezone: string, + dateRange: AnalyticsDateRange = 'this_quarter', +): Promise { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-09-09T12:00:00Z')); + const query: AnalyticsQuery = AnalyticsQuerySchema.parse({ + cube: 'deals', + measures: ['deals.count'], + dimensions: ['deals.probe'], + timeDimensions: [{ dimension, dateRange }], + timezone, + }); + const result = await service.query(query); + return result.rows.map((row) => String(row['deals.probe'])).sort(); +} + +describe('#27272 — a preset dateRange on a declared date field counts calendar days', () => { + const dateRows = DAYS.map((day, i) => ({ id: i + 1, probe: day, close_date: day })); + + for (const zone of ['America/Los_Angeles', 'Asia/Shanghai', 'UTC']) { + it(`${zone}: this_quarter selects Jul 1 through Sep 30`, async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', zone)).toEqual(Q3); + }); + } + + it('Los Angeles: this_month on Sep 9 selects Sep 30 and not Oct 1', async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', 'this_month')).toEqual(['2026-09-30']); + }); + + it('an explicit window keeps its inclusive bare-day reading, untouched', async () => { + const service = await serviceOver(true, dateRows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles', ['2026-07-01', '2026-09-30'])).toEqual(Q3); + }); +}); + +describe('#27272 — a datetime field keeps comparing instants', () => { + // Los Angeles' Q3 opens at 2026-07-01T07:00Z and closes before 2026-10-01T07:00Z. + const instants = [ + '2026-07-01T06:59:59.000Z', // Jun 30, 23:59:59 local — outside + '2026-07-01T07:00:00.000Z', // Jul 1, 00:00 local — inside + '2026-10-01T06:59:59.000Z', // Sep 30, 23:59:59 local — inside + '2026-10-01T07:00:00.000Z', // Oct 1, 00:00 local — outside + ]; + const rows = instants.map((iso, i) => ({ id: i + 1, probe: iso, closed_at: iso })); + + it('Los Angeles: this_quarter on a declared datetime is the zone-midnight instant pair', async () => { + const service = await serviceOver(true, rows); + expect(await probes(service, 'deals.closedAt', 'America/Los_Angeles')).toEqual([ + '2026-07-01T07:00:00.000Z', + '2026-10-01T06:59:59.000Z', + ]); + }); +}); + +describe('#27272 — a field whose type was never declared keeps the instant pair', () => { + it('Los Angeles: an undeclared column is compared as the instants the preset resolved to', async () => { + const rows = ['2026-07-01T06:59:59.000Z', '2026-07-01T07:00:00.000Z'].map((iso, i) => ({ + id: i + 1, + probe: iso, + close_date: iso, + })); + const service = await serviceOver(false, rows); + expect(await probes(service, 'deals.closeDate', 'America/Los_Angeles')).toEqual(['2026-07-01T07:00:00.000Z']); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-analytics.ts b/packages/drivers/driver-memory/src/memory-analytics.ts index 66b171589ce..a0d8cdeee81 100644 --- a/packages/drivers/driver-memory/src/memory-analytics.ts +++ b/packages/drivers/driver-memory/src/memory-analytics.ts @@ -30,6 +30,10 @@ import { // the ONE refusal for a string outside it, shared with the SQL analytics // path so the two backends cannot answer one input differently again. resolveAnalyticsDateRangeString, + // [#27272] ADR-0053's one window rule — a `date` column compares the + // calendar days the period covers in the reference zone — shared with + // service-analytics' faces (#27172) for the same reason as the lowering. + dateRangeWindowBounds, // [#17596] The ONE constructor for that envelope, reused for the ARRAY arm's // own refusal: the shared conformance kit reads `code` + `status`, so the // pair must have a single origin across every face. ⛔ Only the SENTENCE is @@ -1196,7 +1200,11 @@ export class MemoryAnalyticsService implements IAnalyticsService { // the aggregation with NO time predicate emitted at all. const resolved: ResolvedDateRange = Array.isArray(timeDim.dateRange) ? { bounds: explicitDateRangeWindow(timeDim.dateRange), endExclusive: false } - : this.parseDateRangeString(timeDim.dateRange, query.timezone); + : this.parseDateRangeString( + timeDim.dateRange, + query.timezone, + this.driver.isDeclaredDateField(this.extractTableName(cube.sql), fieldPath), + ); const range = resolved.bounds; // The window matches BOTH stored forms of a datetime value — the @@ -2243,13 +2251,26 @@ export class MemoryAnalyticsService implements IAnalyticsService { * explicit `[a, b]` array arm never arrives here at all: it is discriminated * at the call site and keeps its published `$lte` reading. * + * [#27272] On a column the driver holds as declared `date` (`dateColumn`) + * the window is instead the calendar days it covers, with an inclusive end. + * * @throws the ADR-0112 `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope for * a string outside `DATE_RANGE_PRESETS` — the same code, status and * wording the schema door and the SQL analytics path answer with, since * all three call one constructor. */ - private parseDateRangeString(range: string, timezone?: string): ResolvedDateRange { + private parseDateRangeString(range: string, timezone: string | undefined, dateColumn: boolean): ResolvedDateRange { const window = resolveAnalyticsDateRangeString(range, { timezone }); + if (dateColumn) { + // [#27272] A declared `date` column holds bare days, and the preset's + // instants (the zone's midnight) compared to them as TEXT dropped the + // first day and counted the day after the last from UTC westward. ADR-0053's + // one rule, through its one spelling: the calendar days the period covers + // in the reference zone, as the inclusive day pair — which the bound + // construction then reads exactly as a caller's bare-day `[a, b]`. + const days = dateRangeWindowBounds(window, timezone, true); + return { bounds: [days.$gte, days.$lte], endExclusive: false }; + } return { bounds: [window.start, window.end], endExclusive: window.endExclusive }; } diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index d6609e52f3e..161820eb595 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -2359,6 +2359,20 @@ export class InMemoryDriver implements IDataDriver { return this.toStorageForm(object, field, value); } + /** + * [#27272] Is `field` on `object` declared `date`? For the analytics (cube) + * face's `dateRange` window, which compares a `date` column by CALENDAR DAYS + * and every other column by instants (ADR-0053, `dateRangeWindowBounds`). + * + * Keyed on the DECLARED kind ({@link temporalFields}, populated by + * {@link syncSchema}) for the reason {@link filterComparandStorageForm} is: + * only the driver holds it. An object never declared answers `false` — the + * driver does not guess types from values. + */ + isDeclaredDateField(object: string | undefined, field: string): boolean { + return this.temporalKind(object, field) === 'date'; + } + /** * [#5374] The pattern a `$contains` / `$notContains` comparand becomes — the * substring rule itself, for the analytics (cube) face. diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 41872f43db2..dac48bb1ccf 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -13,7 +13,9 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { bucketDateKey, bucketKeyToCalendarRange, + dateRangeWindowBounds, filterTokenContextFrom, + inclusiveCalendarDayWindow, resolveAnalyticsDateRangeString, resolveFilterTokens, wallClockToUtcMs, @@ -658,88 +660,12 @@ export function lowerDatasetCompareDateRange( return { start, end, endExclusive: false }; } -/** - * [#17973] The calendar days a lowered window covers, as the INCLUSIVE - * `[first, last]` pair every piece of `compareTo` math in this module takes. - * - * {@link shiftRange} measures `previousPeriod`'s length as a count of whole - * days between two day-starts, and {@link alignedCompareBucketKey} reads - * `currentRange[1]` as the LAST bucket the window contains — so both bounds - * must name days the window really covers. - * - * ⚠️ `endExclusive` is therefore load-bearing, not decoration: every preset - * reports the first instant AFTER the window (`this_month` ends at the next - * month's start, and a rolling `last_N_days` window at tomorrow's start), so - * the last day it covers is the one holding the instant before that bound. - * Reading that bound as inclusive would make every preset one day too long and - * shift `previousPeriod` by a day. - * - * ## [#18245] ⭐ `timezone` decides WHICH calendar, and it is not optional here - * - * The bounds arriving here are INSTANTS, and the ten calendar presets open and - * close at the reference zone's midnight — `analytics-date-range.ts` renders - * both through `zonedDateStartToUtcMs` precisely so they do. Reading those - * instants on the UTC calendar therefore moves a boundary in every zone whose - * midnight is not UTC's, and it moves it in OPPOSITE directions either side of - * the meridian. MEASURED on `e0d05538c`, `this_month` + `previousYear` frozen - * at 2026-09-09: `Asia/Shanghai` opened at `2025-08-31` (its September midnight - * is the previous UTC day) and `America/New_York` closed at `2025-10-01` (its - * October midnight is the next UTC day) — each 31 days against a 30-day - * September, each still an ordinary `200`. - * - * ⛔ That is a day-boundary projection, ⛔ not an off-by-one: no constant makes - * both sides right, which is why the caller threads the SAME zone - * {@link DatasetExecutor.buildQuery} resolves the primary pass in. - */ -function inclusiveCalendarDayWindow( - window: LoweredDateRangeWindow, - timezone?: string, -): [string, string] { - const endMs = boundInstantMs(window.end); - return [ - calendarDayAt(boundInstantMs(window.start), timezone), - calendarDayAt(window.endExclusive ? endMs - 1 : endMs, timezone), - ]; -} - -/** - * [#27172] The bound pair a lowered `dateRange` window filters one column by — - * the ONE spelling the count (both strategies, the draft preview) and the drill - * (`drill-window.ts`) share. - * - * ADR-0053's one rule: a `datetime` column compares INSTANTS and a `date` - * column compares CALENDAR DAYS in the reference zone. - * - * - A resolved preset (`endExclusive`) on a `date` column is the calendar days - * it covers in `timezone`, through this module's whole-day rule - * {@link inclusiveCalendarDayWindow} — the projection `compareTo` already - * shifts — as the inclusive `{ $gte, $lte }` day pair. - * - A resolved preset on any other column stays the instant pair it resolved - * to, stopping before its end: `{ $gte, $lt }`. - * - A caller's explicit `[a, b]` is handed back bound for bound, with its - * inclusive upper reading (#16179); the shared lowering gives a bare-day end - * its whole-day reading where the column needs one. - * - * ⛔ The preset's instants used to reach a `date` column as they are, and the - * column reads an instant by its leading UTC day (`temporalStorageForm`). A - * zone east of UTC opens at midnight on the PREVIOUS UTC day, so the window - * slid one day early: Shanghai's `this_quarter` counted Jun 30 and dropped - * Sep 30, on both strategies. West of UTC the leading day happens to be the - * zone's own, which is why only eastern tenants saw it. - * - * @param dateColumn - Is the window's column declared `date`? A column whose - * type the caller cannot name answers `false` and keeps the instants. - */ -export function dateRangeWindowBounds( - window: LoweredDateRangeWindow, - timezone: string | undefined, - dateColumn: boolean, -): Record { - if (!window.endExclusive) return { $gte: window.start, $lte: window.end }; - if (!dateColumn) return { $gte: window.start, $lt: window.end }; - const [first, last] = inclusiveCalendarDayWindow(window, timezone); - return { $gte: first, $lte: last }; -} +// [#17973] [#27172] [#27272] The calendar days a lowered window covers +// (`inclusiveCalendarDayWindow`) and the bound pair a window filters one column +// by (`dateRangeWindowBounds`) live in `@objectstack/core`, beside the +// lowering: `driver-memory`'s cube face filters by the same rule and cannot +// import this package. Re-exported so this package's faces keep one import. +export { dateRangeWindowBounds }; /** * Resolve which time dimension a `compareTo` shifts (#5011).