Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/27272-memory-preset-date-field-calendar-days.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions packages/core/src/utils/analytics-date-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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' });
});
});
90 changes: 89 additions & 1 deletion packages/core/src/utils/analytics-date-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<string, string> {
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));
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]): Promise<MemoryAnalyticsService> {
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<string[]> {
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']);
});
});
25 changes: 23 additions & 2 deletions packages/drivers/driver-memory/src/memory-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}

Expand Down
14 changes: 14 additions & 0 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading