From 059664b749482b38a6b195ee7f738f425c863f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:10:04 +0000 Subject: [PATCH 1/5] wip(core,service-analytics): bucket keys spell the year with four digits (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- packages/core/src/utils/datetime.ts | 60 +++++++++++--- .../service-analytics/src/dataset-executor.ts | 78 +++++++++---------- 2 files changed, 88 insertions(+), 50 deletions(-) diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index fda5c68ba9b..fe67aac16ed 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -301,6 +301,12 @@ 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, so 0001..0999 reach this + * function. */ export function bucketDateKey( value: unknown, @@ -318,13 +324,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: @@ -336,6 +342,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: a `date` or `datetime` value names a + * year from 0001 to 9999 at both engine doors, 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). @@ -346,6 +379,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 })); @@ -360,7 +397,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')}`; } /** @@ -397,17 +434,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 })); diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index cd459e5881e..e8e69100712 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -18,6 +18,7 @@ import { resolveFilterTokens, wallClockToUtcMs, zonedDateStartToUtcMs, + type BucketGranularity, type LoweredDateRangeWindow, } from '@objectstack/core'; import type { CompiledDataset, DerivedMeasureSpec } from './dataset-compiler.js'; @@ -570,15 +571,25 @@ const DAY_MS = 86_400_000; * proxy back, round-tripping {@link boundInstantMs} exactly. */ function calendarDayAt(ms: number, timezone?: string): string { - const day = bucketDateKey(ms, 'day', timezone); - if (day == null) { + return bucketKeyAt(ms, 'day', timezone); +} + +/** + * [#20760] The canonical bucket key of the instant at `ms`, at `granularity`: + * `@objectstack/core`'s `bucketDateKey` — the labeller the in-memory grouping + * faces delegate to, whose label is contracted to equal the drivers' — so this + * module spells no key of its own. + */ +function bucketKeyAt(ms: number, granularity: BucketGranularity, timezone?: string): string { + const key = bucketDateKey(ms, granularity, timezone); + if (key == null) { // `bucketDateKey` answers `null` only for an absent or unparseable instant, // and every caller here holds a finite epoch ms this module just computed. - // Loud rather than a fabricated day: same condition, same envelope as an + // Loud rather than a fabricated key: same condition, same envelope as an // unparseable bound above (#5716). - throw datasetInvalidError(`[dataset-executor] compareTo date math produced no calendar day for ${ms}`); + throw datasetInvalidError(`[dataset-executor] compareTo date math produced no ${granularity} bucket for ${ms}`); } - return day; + return key; } function shiftYear(date: string, years: number): string { @@ -823,33 +834,6 @@ export function shiftRange(range: [string, string], kind: CompareTo['kind']): [s // ── compareTo bucket alignment (#6007) ─────────────────────────────────────── -/** - * The ISO-8601 week label (`2026-W23`) of the UTC calendar day at `ms`. - * - * Mirrors the week branch of `@objectstack/objectql`'s `bucketDateValue` — the - * function that MINTS the bucket keys this executor then has to realign. It is - * copied rather than imported because `service-analytics` does not depend on - * `objectql` (it talks to the runtime through `IAnalyticsService`), and the - * copy is not a blind one: {@link bucketKeyAtOrdinal} is pinned round-trip - * against `bucketKeyToCalendarRange` — `@objectstack/core`'s exported INVERSE - * of the same vocabulary, which rejects an impossible week outright — so a - * drift in either direction fails a test rather than mislabelling a bucket. - */ -function isoWeekKeyOfUtcMs(ms: number): string { - const target = new Date(ms); - const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6 - target.setUTCDate(target.getUTCDate() - dayNum + 3); // that week's Thursday - // [#20599] Core's `wallClockToUtcMs`, never `Date.UTC`, which reads a year - // from 0 to 99 as 1900 + year and put this Thursday's January 4 in the 1900s. - const firstThursday = new Date(wallClockToUtcMs({ year: target.getUTCFullYear(), month: 1, day: 4 })); - const weekNo = - 1 + - Math.round( - ((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7, - ); - return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; -} - /** * The ORDINAL of the bucket a UTC calendar day falls in: a monotone integer * that advances by exactly 1 per bucket, at every granularity. @@ -890,25 +874,39 @@ export function bucketOrdinalOfDay(ymd: string, granularity: DateGranularityValu /** * The canonical bucket KEY at an ordinal — the inverse of - * {@link bucketOrdinalOfDay}, and the only place this package mints a bucket key - * of its own. + * {@link bucketOrdinalOfDay}. * * The keys produced here MUST be byte-identical to the ones the runtime's * grouping produced for the primary pass, because they are compared as merge * keys: `2026-01`, `2026-Q1`, `2026`, `2026-01-07`, `2026-W03`. That equality is * pinned round-trip against `bucketKeyToCalendarRange` rather than asserted by * eye — see `dataset-compare-bucket-alignment.test.ts`. + * + * [#20760] ⛔ This function spells no key itself. It finds the UTC instant the + * ordinal's bucket starts at, and `@objectstack/core`'s `bucketDateKey` + * spells the key of that instant ({@link bucketKeyAt}), so a minted key and a + * grouped key cannot differ in spelling. A year below 1000 is four digits on both (`0050-06`, + * `0049-W52`). It replaced a local spelling of each granularity and a private + * copy of the ISO week rule, which wrote such a year unpadded (`50-06`, + * `49-W52`). */ export function bucketKeyAtOrdinal(ordinal: number, granularity: DateGranularityValue): string { switch (granularity) { case 'year': - return String(ordinal); - case 'quarter': - return `${Math.floor(ordinal / 4)}-Q${(ordinal % 4) + 1}`; - case 'month': - return `${Math.floor(ordinal / 12)}-${String((ordinal % 12) + 1).padStart(2, '0')}`; + return bucketKeyAt(wallClockToUtcMs({ year: ordinal, month: 1, day: 1 }), 'year'); + case 'quarter': { + const year = Math.floor(ordinal / 4); + const quarter = ordinal - year * 4; // 0..3 + return bucketKeyAt(wallClockToUtcMs({ year, month: quarter * 3 + 1, day: 1 }), 'quarter'); + } + case 'month': { + const year = Math.floor(ordinal / 12); + const month = ordinal - year * 12; // 0..11 + return bucketKeyAt(wallClockToUtcMs({ year, month: month + 1, day: 1 }), 'month'); + } case 'week': - return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS); + // The Monday the ordinal's ISO week starts on (see bucketOrdinalOfDay). + return bucketKeyAt(ordinal * 7 * DAY_MS - 3 * DAY_MS, 'week'); case 'day': default: return calendarDayAt(ordinal * DAY_MS); From 3716880dedc343d6a5840689d4cd3e787e479a46 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:17:13 +0000 Subject: [PATCH 2/5] wip(test): pin four-digit bucket-key years across the faces (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- ...atetime-bucket-key-four-digit-year.test.ts | 112 ++++++++++++++++++ .../src/utils/datetime-year-below-100.test.ts | 14 +-- .../memory-analytics-four-digit-year.test.ts | 65 ++++++++++ ...-driver-bucket-key-four-digit-year.test.ts | 91 ++++++++++++++ ...memory-aggregation-four-digit-year.test.ts | 48 ++++++++ .../bucket-key-four-digit-year.test.ts | 100 ++++++++++++++++ .../__tests__/week-key-year-below-100.test.ts | 14 ++- 7 files changed, 431 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts create mode 100644 packages/drivers/driver-memory/src/memory-analytics-four-digit-year.test.ts create mode 100644 packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts create mode 100644 packages/objectql/src/in-memory-aggregation-four-digit-year.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/bucket-key-four-digit-year.test.ts diff --git a/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts b/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts new file mode 100644 index 00000000000..66238833f57 --- /dev/null +++ b/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts @@ -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]> = [ + ['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: a `date` or `datetime` value names a year from 0001 to 9999 at + // both engine doors. 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(); + }); +}); diff --git a/packages/core/src/utils/datetime-year-below-100.test.ts b/packages/core/src/utils/datetime-year-below-100.test.ts index 7c75bcde8ac..625ee2641af 100644 --- a/packages/core/src/utils/datetime-year-below-100.test.ts +++ b/packages/core/src/utils/datetime-year-below-100.test.ts @@ -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; }; @@ -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' }); }); diff --git a/packages/drivers/driver-memory/src/memory-analytics-four-digit-year.test.ts b/packages/drivers/driver-memory/src/memory-analytics-four-digit-year.test.ts new file mode 100644 index 00000000000..d1242977f07 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-analytics-four-digit-year.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts b/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts new file mode 100644 index 00000000000..7fd6335be63 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20760] For a day in 0001..0999, `SqlDriver` on SQLite and the in-memory +// face answer the same bucket key at every granularity SQLite buckets in SQL. +// +// The in-memory side is `@objectstack/core`'s `bucketDateKey`, the labeller the +// engine's in-memory `groupBy` and the memory cube face both delegate to (their +// own pins: objectql `in-memory-aggregation-four-digit-year.test.ts`, +// driver-memory `memory-analytics-four-digit-year.test.ts`). SQLite's +// `strftime('%Y')` pads the year to four digits; before the card the helper +// did not, so 0050-06-15 keyed `0050-06` here and `50-06` in memory. `week` is +// not bucketed in SQL on SQLite (the driver refuses it and the engine buckets +// it in memory), so both paths already share the helper there. +// +// The rows are written through `initObjects` + `create`, the path a real +// object takes: a `date` column (`0001..9999`) and a `datetime` column holding +// a stored instant on the same day. Every expected key is also spelled +// literally, so the pin fails if both sides drift together. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { bucketDateKey } from '@objectstack/core'; +import { SqlDriver } from '../src/index.js'; + +type Granularity = 'day' | 'month' | 'quarter' | 'year'; + +const TABLE = 'year_spelling_events'; + +const FIXTURE = [ + { id: 'r1', iso: '0050-06-15T10:00:00.000Z' }, + { id: 'r2', iso: '0999-06-15T10:00:00.000Z' }, + // The control. + { id: 'r3', iso: '2026-06-15T10:00:00.000Z' }, +]; + +const EXPECTED: Record = { + 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'], +}; + +describe('[#20760] SqlDriver on SQLite and the in-memory face key a year below 1000 alike', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { name: TABLE, fields: { happened_at: { type: 'datetime' }, happened_on: { type: 'date' } } }, + ]); + for (const { id, iso } of FIXTURE) { + await driver.create( + TABLE, + { id, happened_at: new Date(iso), happened_on: iso.slice(0, 10) }, + { bypassTenantAudit: true }, + ); + } + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + async function sqlKeys(field: string, g: Granularity): Promise { + const rows = await driver.aggregate(TABLE, { + groupBy: [{ field, dateGranularity: g }], + aggregations: [{ function: 'count', alias: 'n' }], + } as any); + return rows.map((r: any) => r[field]).sort(); + } + + for (const g of Object.keys(EXPECTED) as Granularity[]) { + describe(`at ${g}`, () => { + it('the in-memory face spells the key the test expects', () => { + expect(FIXTURE.map((r) => bucketDateKey(r.iso, g)).sort()).toEqual(EXPECTED[g]); + expect(FIXTURE.map((r) => bucketDateKey(r.iso.slice(0, 10), g)).sort()).toEqual(EXPECTED[g]); + }); + + it('the datetime column keys the same in SQL', async () => { + expect(await sqlKeys('happened_at', g)).toEqual(EXPECTED[g]); + }); + + it('the date column keys the same in SQL', async () => { + expect(await sqlKeys('happened_on', g)).toEqual(EXPECTED[g]); + }); + }); + } +}); diff --git a/packages/objectql/src/in-memory-aggregation-four-digit-year.test.ts b/packages/objectql/src/in-memory-aggregation-four-digit-year.test.ts new file mode 100644 index 00000000000..c0e9c4d4d4d --- /dev/null +++ b/packages/objectql/src/in-memory-aggregation-four-digit-year.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20760] The engine's in-memory `groupBy` face keys a date bucket in +// 0001..0999 with a four-digit year, the key the SQL drivers' bucket +// expressions answer for the same rows (`strftime('%Y-%m')` → `0050-06`). It +// delegates to `@objectstack/core`'s `bucketDateKey`; this pins the face, so a +// local spelling here cannot drift from the pushed-down path. +// +// Before the card this face answered `50`, `50-Q2`, `50-06`, `50-06-15` and +// `50-W24` for 0050-06-15 while SQLite answered `0050`, `0050-Q2`, … so the +// same `groupBy` keyed the same rows differently on the two paths. + +import { describe, it, expect } from 'vitest'; +import { applyInMemoryAggregation } from './in-memory-aggregation.js'; + +/** A `date` value, a stored `datetime` instant on the same day, a 0999 day and the 2026 control. */ +const ROWS = [ + { id: 1, closed_at: '0050-06-15' }, + { id: 2, closed_at: '0050-06-15T10:00:00.000Z' }, + { id: 3, closed_at: '0999-06-15' }, + { id: 4, closed_at: '2026-06-15' }, +]; + +const EXPECTED: Record<'year' | 'quarter' | 'month' | 'day' | 'week', Record> = { + year: { '0050': 2, '0999': 1, '2026': 1 }, + quarter: { '0050-Q2': 2, '0999-Q2': 1, '2026-Q2': 1 }, + month: { '0050-06': 2, '0999-06': 1, '2026-06': 1 }, + day: { '0050-06-15': 2, '0999-06-15': 1, '2026-06-15': 1 }, + week: { '0050-W24': 2, '0999-W24': 1, '2026-W25': 1 }, +}; + +describe('[#20760] in-memory groupBy keys a year below 1000 with four digits', () => { + it.each(Object.entries(EXPECTED))('at %s', (granularity, expected) => { + const out = applyInMemoryAggregation(ROWS, { + groupBy: [{ field: 'closed_at', dateGranularity: granularity as 'year' }], + aggregations: [{ function: 'count', alias: 'n' }], + }); + expect(Object.fromEntries(out.map((r) => [r.closed_at, r.n]))).toEqual(expected); + }); + + it('keys early January 0050 in its ISO week-year, 0049', () => { + const out = applyInMemoryAggregation([{ id: 1, closed_at: '0050-01-01' }], { + groupBy: [{ field: 'closed_at', dateGranularity: 'week' }], + aggregations: [{ function: 'count', alias: 'n' }], + }); + expect(out).toEqual([{ closed_at: '0049-W52', n: 1 }]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/bucket-key-four-digit-year.test.ts b/packages/services/service-analytics/src/__tests__/bucket-key-four-digit-year.test.ts new file mode 100644 index 00000000000..c0925ce3a2f --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/bucket-key-four-digit-year.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20760] This package's bucket-key sites read and write the key a grouped row + * carries, with its year in four digits, for a day in 0001..0999. + * + * - `bucketKeyAtOrdinal` (the `compareTo` alignment) mints its key through + * `@objectstack/core`'s `bucketDateKey`. It spelled every granularity itself + * and carried a private copy of the ISO week rule, so it minted `50-06` and + * `49-W52` while the grouped rows it is merged with carry `0050-06` and + * `0049-W52` (a SQL driver's bucket expression, and since this card the + * in-memory faces too). A comparison row then never merged onto its bucket. + * - The drill-down (`drillRanges`) turns a grouped row's key back into a + * calendar range through core's `bucketKeyToCalendarRange`, whose week arm + * checked a padded key against the unpadded label: `0050-W01` found no range. + * + * Pins: 0001, 0050 and 0999 with a 2026 control. Every expected key and bound + * is spelled literally. + */ + +import { describe, it, expect } from 'vitest'; +import { bucketDateKey } from '@objectstack/core'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; +import { alignedCompareBucketKey, bucketKeyAtOrdinal, bucketOrdinalOfDay } from '../dataset-executor.js'; + +const GRANULARITIES = ['year', 'quarter', 'month', 'day', 'week'] as const; + +describe('[#20760] bucketKeyAtOrdinal mints the key a grouped row carries', () => { + it.each([ + ['0001-06-15', { year: '0001', quarter: '0001-Q2', month: '0001-06', day: '0001-06-15', week: '0001-W24' }], + ['0050-06-15', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15', week: '0050-W24' }], + ['0050-01-01', { year: '0050', quarter: '0050-Q1', month: '0050-01', day: '0050-01-01', week: '0049-W52' }], + ['0999-06-15', { year: '0999', quarter: '0999-Q2', month: '0999-06', day: '0999-06-15', week: '0999-W24' }], + ['2026-06-15', { year: '2026', quarter: '2026-Q2', month: '2026-06', day: '2026-06-15', week: '2026-W25' }], + ] as const)('%s', (day, expected) => { + for (const g of GRANULARITIES) { + const minted = bucketKeyAtOrdinal(bucketOrdinalOfDay(day, g), g); + expect(minted, g).toBe(expected[g]); + // The grouped row's key for the same day, from the labeller the + // in-memory faces delegate to. + expect(minted, g).toBe(bucketDateKey(day, g)); + } + }); + + it.each([ + ['0049-06', 'month', '0050-06'], + ['0049-Q2', 'quarter', '0050-Q2'], + ['0049', 'year', '0050'], + ['0049-06-15', 'day', '0050-06-15'], + ['0049-W24', 'week', '0050-W24'], + // The control. + ['2025-06', 'month', '2026-06'], + ] as const)('restates the previous-year key %s (%s) as %s', (key, g, current) => { + const year = Number(current.slice(0, 4)); + const pad = (y: number) => String(y).padStart(4, '0'); + expect( + alignedCompareBucketKey( + key, + g, + 'previousYear', + [`${pad(year)}-01-01`, `${pad(year)}-12-31`], + [`${pad(year - 1)}-01-01`, `${pad(year - 1)}-12-31`], + ), + ).toBe(current); + }); +}); + +describe('[#20760] a drill-down from a week key in 0001..0999 finds its range', () => { + const CTX = { tenantId: 'org_A' } as ExecutionContext; + const weekly = DatasetSchema.parse({ + name: 'task_metrics', label: 'Tasks', object: 'showcase_task', include: [], + dimensions: [{ name: 'created_at', field: 'created_at', type: 'date', dateGranularity: 'week' }], + measures: [{ name: 'task_count', aggregate: 'count' }], + }); + + /** A service whose grouped row carries the week key of `day`, as the in-memory face writes it. */ + async function drill(day: string) { + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ created_at: bucketDateKey(day, 'week'), task_count: 1 }], + // A tz-naive calendar `date` → the range is the calendar bounds themselves. + sourceFieldMeta: (_o, f) => (f === 'created_at' ? { type: 'date' } : undefined), + }); + return (await svc.queryDataset(weekly, { dimensions: ['created_at'], measures: ['task_count'] }, CTX)) as any; + } + + it.each([ + ['0050-01-05', '0050-W01', '0050-01-03', '0050-01-10'], + ['0050-01-01', '0049-W52', '0049-12-27', '0050-01-03'], + ['0999-06-15', '0999-W24', '0999-06-10', '0999-06-17'], + // The control. + ['2026-01-01', '2026-W01', '2025-12-29', '2026-01-05'], + ])('%s keys %s and drills to %s up to %s', async (day, key, gte, lt) => { + const r = await drill(day); + expect(r.rows[0].created_at).toBe(key); + expect(r.drillRanges).toEqual([{ created_at: { field: 'created_at', gte, lt } }]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts b/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts index e62e605ab55..4751f4ce592 100644 --- a/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts +++ b/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts @@ -9,8 +9,8 @@ * row on 0050-06-15 was keyed to a Monday in 1950. * - The dataset executor's `compareTo` alignment counts bucket ordinals from a * bound's day (core's `zonedDateStartToUtcMs`, the same remap) and mints a - * week key back from an ordinal (`isoWeekKeyOfUtcMs`, whose January 4 was - * built by `Date.UTC` too). + * week key back from an ordinal (then a private week rule, whose January 4 + * was built by `Date.UTC` too; since #20760 core's `bucketDateKey`). * * Both build through core's `wallClockToUtcMs` now. Pins: 0001, 0050 and 0099, * with 0100 (the first year `Date.UTC` reads as written) and a 2026 control, @@ -62,10 +62,12 @@ describe.each(HOSTS)('on a %s host', (host) => { }); describe('[#20599] compareTo bucket ordinals count a day in 0001..0099 in its own year', () => { - // A minted key's year below 1000 is spelled unpadded (`50-06`): the - // unpadded-key family, which this card leaves alone. The keys are read as - // numbers here, whatever their padding. - const numbers = (key: string) => key.split(/-[WQ]?/).map(Number); + // [#20760] A minted key's year is four digits (`0050-06`), so the key is + // read as numbers only when its year is; an unpadded key reads `NaN` and + // fails the assertion. The spelling itself is pinned in + // `bucket-key-four-digit-year.test.ts`. + const numbers = (key: string) => + /^\d{4}(-|$)/.test(key) ? key.split(/-[WQ]?/).map(Number) : [Number.NaN]; it.each([ ['0001-06-15', 1], From 70a5fbf3cf60c17f33d1a6ee47331857f1ffb937 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:23:17 +0000 Subject: [PATCH 3/5] chore(changeset): four-digit bucket-key years for core and service-analytics (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .changeset/20760-bucket-key-four-digit-year.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/20760-bucket-key-four-digit-year.md diff --git a/.changeset/20760-bucket-key-four-digit-year.md b/.changeset/20760-bucket-key-four-digit-year.md new file mode 100644 index 00000000000..2328550179b --- /dev/null +++ b/.changeset/20760-bucket-key-four-digit-year.md @@ -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`. From 076ba0c599cf20411ae34d090530c0e1be536b2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:24:25 +0000 Subject: [PATCH 4/5] docs(core): state the merged datetime floor beside the bucket-key year spelling (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../utils/datetime-bucket-key-four-digit-year.test.ts | 6 +++--- packages/core/src/utils/datetime.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts b/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts index 66238833f57..02304c33ff7 100644 --- a/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts +++ b/packages/core/src/utils/datetime-bucket-key-four-digit-year.test.ts @@ -91,9 +91,9 @@ describe('[#20760] bucketKeyToCalendarRange reads exactly what bucketDateKey wri }); describe('[#20760] a year outside 0000..9999 has no four-digit form, and is not padded into one', () => { - // Not reached: a `date` or `datetime` value names a year from 0001 to 9999 at - // both engine doors. Stated so a negative year is never spelled as a padded - // fragment (`00-1`) that reads as a key. + // 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); diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index 11efc2cf803..674356907b5 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -306,8 +306,9 @@ export function isBucketGranularity(value: unknown): value is BucketGranularity * [#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, so 0001..0999 reach this - * function. + * 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, @@ -357,9 +358,9 @@ export function bucketDateKey( * * 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: a `date` or `datetime` value names a - * year from 0001 to 9999 at both engine doors, and - * {@link bucketKeyToCalendarRange} reads neither spelling. + * 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); From c03977051a1869f6dd3e8c66e9f4dc49abbab0df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:57:18 +0000 Subject: [PATCH 5/5] test(driver-sql): type the bucket-key pin's aggregate options (#20760) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../src/sql-driver-bucket-key-four-digit-year.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts b/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts index 7fd6335be63..8bfb488f571 100644 --- a/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-bucket-key-four-digit-year.test.ts @@ -68,8 +68,8 @@ describe('[#20760] SqlDriver on SQLite and the in-memory face key a year below 1 const rows = await driver.aggregate(TABLE, { groupBy: [{ field, dateGranularity: g }], aggregations: [{ function: 'count', alias: 'n' }], - } as any); - return rows.map((r: any) => r[field]).sort(); + }); + return rows.map((r: Record) => String(r[field])).sort(); } for (const g of Object.keys(EXPECTED) as Granularity[]) {