Skip to content
12 changes: 12 additions & 0 deletions .changeset/20760-bucket-key-four-digit-year.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/core': patch
'@objectstack/service-analytics': patch
---

A date-bucket key spells its year with four digits at every granularity, as the SQL drivers' bucket expressions do, so the in-memory and pushed-down paths key a day in 0001..0999 alike and a drill-down from such a key finds its range.

A `date` value names a year from 0001 to 9999, so these keys are reachable through a `date` field and through a stored `datetime` row. For 0050-06-15, `strftime('%Y-%m')` on SQLite and `to_char(…, 'YYYY-MM')` on PostgreSQL answer `0050-06`, while `bucketDateKey` answered `50-06`: the same `groupBy` keyed the same rows differently depending on which path ran it.

- **`@objectstack/core` `bucketDateKey`** pads the year to four digits: `0050`, `0050-Q2`, `0050-06`, `0050-06-15`, and the ISO week key `0050-W24` (early January 0050 is `0049-W52`, its ISO week-year). The engine's in-memory `groupBy` and the memory cube face delegate to it, so both now answer the drivers' key. A year from 1000 to 9999 is spelled as before.
- **`@objectstack/core` `bucketKeyToCalendarRange`** reads exactly what `bucketDateKey` writes. Its week arm checked a key against the unpadded label, so a padded key such as `0050-W01` answered `null`; it now answers `{ start: '0050-01-03', end: '0050-01-10' }`. An unpadded key (`50-06`, `49-W52`) is not a bucket key and still answers `null`.
- **`@objectstack/service-analytics`** mints the `compareTo` alignment key through `bucketDateKey` instead of spelling it locally, so a comparison row in 0001..0999 merges onto its bucket (`0050-06`) instead of being appended under `50-06`.
112 changes: 112 additions & 0 deletions packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#20760] A bucket key spells its year with four digits at every granularity,
// as the drivers' bucket expressions do (`strftime('%Y')` on SQLite), and
// `bucketKeyToCalendarRange` reads exactly what `bucketDateKey` writes.
//
// A `date` value keeps the years 0001..9999, so a key in 0001..0999 is
// reachable through a `date` field and through a stored `datetime` row. Before
// this card the writer spelled such a year unpadded (`50-06`, `49-W52`) while
// SQL answered `0050-06`, and the reader's week arm checked a padded key
// against the unpadded label, so `0050-W01` found no range.
//
// Pins: 0001, 0050 and 0999 at every granularity, the ISO week-year at a year
// boundary (early January 0050 is in `0049-W52`), a drill-down from
// `0050-W01`, and a 2026 control. Every expected key is spelled literally,
// never computed by the code under test.

import { describe, it, expect } from 'vitest';
import { bucketDateKey, bucketKeyToCalendarRange, type BucketGranularity } from './datetime.js';

const GRANULARITIES = ['year', 'quarter', 'month', 'day', 'week'] as const satisfies readonly BucketGranularity[];

/** instant → the key at year / quarter / month / day / week. */
const KEYS: ReadonlyArray<readonly [string, Record<BucketGranularity, string>]> = [
['0001-01-01T10:00:00.000Z', { year: '0001', quarter: '0001-Q1', month: '0001-01', day: '0001-01-01', week: '0001-W01' }],
['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15', week: '0050-W24' }],
// The ISO week-numbering year is the previous calendar year here.
['0050-01-01T10:00:00.000Z', { year: '0050', quarter: '0050-Q1', month: '0050-01', day: '0050-01-01', week: '0049-W52' }],
['0999-06-15T10:00:00.000Z', { year: '0999', quarter: '0999-Q2', month: '0999-06', day: '0999-06-15', week: '0999-W24' }],
// The control: a four-digit year is spelled as it always was.
['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', day: '2026-06-15', week: '2026-W25' }],
];

describe('[#20760] bucketDateKey spells the year with four digits', () => {
describe.each(KEYS)('%s', (instant, expected) => {
it.each(GRANULARITIES)('at %s', (g) => {
expect(bucketDateKey(instant, g)).toBe(expected[g]);
// A `Date` and epoch milliseconds name the same instant and get the same key.
expect(bucketDateKey(new Date(instant), g)).toBe(expected[g]);
expect(bucketDateKey(Date.parse(instant), g)).toBe(expected[g]);
});
});

it('a `date` value, the bare `YYYY-MM-DD` form, keys like its midnight', () => {
expect(bucketDateKey('0050-06-15', 'day')).toBe('0050-06-15');
expect(bucketDateKey('0050-06-15', 'month')).toBe('0050-06');
expect(bucketDateKey('0999-12-31', 'year')).toBe('0999');
});

it('the week year of a reference zone is four digits too', () => {
// Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai: week 1 of 0050.
const instant = '0050-01-02T20:00:00.000Z';
expect(bucketDateKey(instant, 'week')).toBe('0049-W52');
expect(bucketDateKey(instant, 'week', 'Asia/Shanghai')).toBe('0050-W01');
expect(bucketDateKey(instant, 'day', 'Asia/Shanghai')).toBe('0050-01-03');
});
});

describe('[#20760] bucketKeyToCalendarRange reads exactly what bucketDateKey writes', () => {
describe.each(KEYS)('the keys of %s', (instant, expected) => {
it.each(GRANULARITIES)('at %s span a range whose first day keys back to it', (g) => {
const range = bucketKeyToCalendarRange(expected[g], g);
expect(range).not.toBeNull();
expect(bucketDateKey(range!.start, g)).toBe(expected[g]);
expect(range!.start <= instant.slice(0, 10) && instant.slice(0, 10) < range!.end).toBe(true);
});
});

it.each([
// A drill-down from the padded week key a SQL driver answers.
['0050-W01', '0050-01-03', '0050-01-10'],
// The ISO week-year boundary: the week runs into January 0050.
['0049-W52', '0049-12-27', '0050-01-03'],
['0999-W24', '0999-06-10', '0999-06-17'],
// The control.
['2026-W01', '2025-12-29', '2026-01-05'],
])('week %s is %s up to %s', (key, start, end) => {
expect(bucketKeyToCalendarRange(key, 'week')).toEqual({ start, end });
});

it.each([
['50', 'year'],
['50-Q2', 'quarter'],
['50-06', 'month'],
['50-06-15', 'day'],
['49-W52', 'week'],
['999-W24', 'week'],
] as const)('does not read the unpadded spelling %s (%s): nothing writes it', (key, g) => {
expect(bucketKeyToCalendarRange(key, g)).toBeNull();
});
});

describe('[#20760] a year outside 0000..9999 has no four-digit form, and is not padded into one', () => {
// Not reached: at both engine doors a `date` value names a year from 0001 to
// 9999 and a `datetime` one a year from 1000 to 9999. Stated so a negative
// year is never spelled as a padded fragment (`00-1`) that reads as a key.
const inYear = (year: number) => {
const d = new Date(0);
d.setUTCFullYear(year, 5, 15);
return d;
};

it.each([
[-1, '-1', '-1-06-15'],
[10000, '10000', '10000-06-15'],
])('year %i keys as %s and %s, and no range reads it', (year, yearKey, dayKey) => {
expect(bucketDateKey(inYear(year), 'year')).toBe(yearKey);
expect(bucketDateKey(inYear(year), 'day')).toBe(dayKey);
expect(bucketKeyToCalendarRange(yearKey, 'year')).toBeNull();
expect(bucketKeyToCalendarRange(dayKey, 'day')).toBeNull();
});
});
14 changes: 7 additions & 7 deletions packages/core/src/utils/datetime-year-below-100.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,12 @@ describe.each(HOSTS)('on a %s host', (host) => {
});

describe('[#20599] bucketDateKey(week) puts a day in 0001..0099 in its own ISO week', () => {
// A key's year below 1000 is spelled unpadded (`49-W52`): the unpadded-key
// family, which this card leaves alone. So the key is read as numbers here,
// whatever its padding, and asserted as the ISO week of the day.
// [#20760] The key's year is four digits (`0049-W52`), so the key is read
// as a four-digit year and a week, and asserted as the ISO week of the
// day; an unpadded key does not parse and fails the assertion. The
// spelling itself is pinned in `datetime-bucket-key-four-digit-year.test.ts`.
const weekOf = (key: string | null) => {
const m = /^(\d+)-W(\d{2})$/.exec(String(key));
const m = /^(\d{4})-W(\d{2})$/.exec(String(key));
return m ? { year: Number(m[1]), week: Number(m[2]) } : key;
};

Expand Down Expand Up @@ -242,9 +243,8 @@ describe.each(HOSTS)('on a %s host', (host) => {
expect(bucketKeyToCalendarRange('2026-02-29', 'day')).toBeNull();
});

// The week arm validates a reconstructed Monday against the week LABEL,
// which is spelled unpadded below the year 1000 (the unpadded-key family,
// left alone here), so it is pinned on the 2026 control only.
// [#20760] The week arm's keys in 0001..0999 (`0050-W01`, `0049-W52`) are
// pinned in `datetime-bucket-key-four-digit-year.test.ts`.
it('week 2026-W01 (the control)', () => {
expect(bucketKeyToCalendarRange('2026-W01', 'week')).toEqual({ start: '2025-12-29', end: '2026-01-05' });
});
Expand Down
61 changes: 51 additions & 10 deletions packages/core/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ export function isBucketGranularity(value: unknown): value is BucketGranularity
* Null and unparseable deliberately share one bucket: SQL cannot tell them apart
* either (`strftime('%Y-%m', 'not-a-date')` is NULL), and splitting them here
* would re-open the seam this function exists to close.
*
* [#20760] The year of every key is spelled with four digits
* ({@link bucketKeyYear}): `0050`, `0050-Q2`, `0050-06`, `0050-06-15`,
* `0049-W52` — what the drivers' bucket expressions answer for the same
* instant. A `date` value keeps the years 0001..9999, and a `datetime` row
* stored before the engine doors refused a year below 1000 can still hold one,
* so 0001..0999 reach this function.
*/
export function bucketDateKey(
value: unknown,
Expand All @@ -319,13 +326,13 @@ export function bucketDateKey(
const { year: y, month: m, day } = calendarPartsInTzOrUtc(d, timezone);
switch (granularity) {
case 'year':
return String(y);
return bucketKeyYear(y);
case 'quarter':
return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
return `${bucketKeyYear(y)}-Q${Math.floor((m - 1) / 3) + 1}`;
case 'month':
return `${y}-${String(m).padStart(2, '0')}`;
return `${bucketKeyYear(y)}-${String(m).padStart(2, '0')}`;
case 'day':
return `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return bucketDayKey(y, m, day);
case 'week':
return isoWeekLabelFromCalendarDay(y, m, day);
default:
Expand All @@ -337,6 +344,33 @@ export function bucketDateKey(
}
}

/**
* [#20760] The year of a bucket key, spelled with four digits: `50` is
* `0050`, `999` is `0999`, `2026` is `2026`.
*
* The ONE statement of the key's year spelling. Every key
* {@link bucketDateKey} writes, the ISO week label, and the calendar bounds
* {@link bucketKeyToCalendarRange} answers spell their year through it, so
* the writer and the reader cannot disagree about it. It is the width the
* drivers' bucket expressions answer (`strftime('%Y')` on SQLite, `YYYY` /
* `IYYY` in PostgreSQL's `to_char`, `%Y` / `%x` in MySQL's `date_format`),
* and `bucketDateKey`'s contract is that its label equals theirs.
*
* A year below 0 has no four-digit form and keeps its plain spelling (`-1`),
* never a padded fragment such as `00-1`; a year past 9999 is longer than four
* digits already. Neither is reached: at both engine doors a `date` value
* names a year from 0001 to 9999 and a `datetime` one a year from 1000 to
* 9999, and {@link bucketKeyToCalendarRange} reads neither spelling.
*/
function bucketKeyYear(year: number): string {
return year >= 0 ? String(year).padStart(4, '0') : String(year);
}

/** [#20760] The `day` key (`YYYY-MM-DD`) of a calendar day, `month` 1-12. */
function bucketDayKey(year: number, month: number, day: number): string {
return `${bucketKeyYear(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}

/**
* ISO-8601 week label (Mon-start weeks, week 1 = the week of the first
* Thursday) of a calendar day given that day's parts (`month` is 1-12).
Expand All @@ -347,6 +381,10 @@ export function bucketDateKey(
*
* [#20599] Both days are built by {@link wallClockToUtcMs}, so a day in
* 0001..0099 lands in its own ISO week, never in the 1900s one.
*
* [#20760] The label's year is the ISO week-numbering year, spelled with four
* digits ({@link bucketKeyYear}). Early in January it can be the previous
* calendar year: 0050-01-01 is in `0049-W52`.
*/
function isoWeekLabelFromCalendarDay(year: number, month: number, day: number): string {
const target = new Date(wallClockToUtcMs({ year, month, day }));
Expand All @@ -361,7 +399,7 @@ function isoWeekLabelFromCalendarDay(year: number, month: number, day: number):
((firstThursday.getUTCDay() + 6) % 7)) /
7,
);
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
return `${bucketKeyYear(target.getUTCFullYear())}-W${String(weekNo).padStart(2, '0')}`;
}

/**
Expand Down Expand Up @@ -398,17 +436,20 @@ function isoWeekLabelUtc(d: Date): string {
* never the 1900s one. The arms lean on its rollover, which is `Date.UTC`'s:
* month 13 is next January (Q4's and December's end), and day 32 the next
* month.
*
* [#20760] It reads exactly what {@link bucketDateKey} writes: a four-digit
* year at every granularity, the week key included (`0050-W01`). The day and
* week arms check a key against the label the writer gives the reconstructed
* day, and every bound is spelled by the writer's own day key, so the reader
* cannot drift from the writer. An unpadded key (`50-06`, `49-W52`) is not a
* bucket key and answers `null`.
*/
export function bucketKeyToCalendarRange(
key: string | null | undefined,
granularity: BucketGranularity,
): { start: string; end: string } | null {
if (typeof key !== 'string' || key.length === 0) return null;
const fmt = (dt: Date) =>
`${String(dt.getUTCFullYear()).padStart(4, '0')}-${String(dt.getUTCMonth() + 1).padStart(
2,
'0',
)}-${String(dt.getUTCDate()).padStart(2, '0')}`;
const fmt = (dt: Date) => bucketDayKey(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate());
/** Midnight UTC of `year`-`month`-`day`, `month` 1-12, rolled over past its end. */
const utcDay = (year: number, month: number, day: number) =>
new Date(wallClockToUtcMs({ year, month, day }));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#20760] The memory cube face labels a time bucket in 0001..0999 with a
// four-digit year — `0050`, `0050-Q2`, `0050-06`, `0050-06-15`, `0050-W24` —
// the key a SQL driver's bucket expression answers for the same instant. It
// folds the key through `@objectstack/core`'s `bucketDateKey`; this pins the
// face. Before the card it answered `50`, `50-Q2`, `50-06`, `50-06-15` and
// `50-W24`, and a drill-down from such a key found no range.

import { describe, it, expect } 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 cubes: Cube[] = [
{
name: 'events',
title: 'Events',
sql: 'events',
measures: { count: { label: 'Event Count', type: 'count', sql: 'id' } },
dimensions: {
createdAt: {
label: 'Created At',
type: 'time',
sql: 'created_at',
granularities: ['day', 'week', 'month', 'quarter', 'year'],
},
},
},
];

const ROWS = [
{ id: 1, created_at: '0050-06-15T10:00:00.000Z' },
{ id: 2, created_at: '0999-06-15T10:00:00.000Z' },
// The control.
{ id: 3, created_at: '2026-06-15T10:00:00.000Z' },
];

async function labels(granularity: 'day' | 'week' | 'month' | 'quarter' | 'year'): Promise<unknown[]> {
const driver = new InMemoryDriver({ initialData: { events: ROWS } });
await driver.connect();
const service = new MemoryAnalyticsService({ driver, cubes });
const result = await service.query(
AnalyticsQuerySchema.parse({
cube: 'events',
measures: ['events.count'],
dimensions: ['events.createdAt'],
timeDimensions: [{ dimension: 'events.createdAt', granularity }],
} satisfies AnalyticsQuery),
);
return result.rows.map((r) => r['events.createdAt']).sort();
}

describe('[#20760] the memory cube face labels a year below 1000 with four digits', () => {
it.each([
['year', ['0050', '0999', '2026']],
['quarter', ['0050-Q2', '0999-Q2', '2026-Q2']],
['month', ['0050-06', '0999-06', '2026-06']],
['day', ['0050-06-15', '0999-06-15', '2026-06-15']],
['week', ['0050-W24', '0999-W24', '2026-W25']],
] as const)('at %s', async (granularity, expected) => {
expect(await labels(granularity)).toEqual(expected);
});
});
Loading
Loading