Skip to content

Commit 856321f

Browse files
fix(core,service-analytics): a date-bucket key spells its year with four digits, and the reader reads the week key the writer writes (#20760) (#20865)
Fixes #20760 Clause-②: no ## What changes Triage direction 5903751640, as ruled: one writer, one reader, no local padding. Nothing in any driver's SQL moves. - **One writer: `@objectstack/core` `bucketDateKey` and its ISO week label.** A private `bucketKeyYear(year)` spells the year of a bucket key with four digits, and every arm goes through it: `year`, `quarter`, `month`, `day` (through a private `bucketDayKey`) and the week label (`isoWeekLabelFromCalendarDay`). A year from 1000 to 9999 is spelled as before. No export is added or removed. - **One reader: `bucketKeyToCalendarRange`.** Its patterns already required a four-digit year. The defect was the week arm: it checks a key against the label the writer gives the reconstructed Monday, and that label was unpadded, so `0050-W01` answered `null`. Its bounds are now spelled by the writer's own day key (`fmt` is `bucketDayKey`), so the two share one spelling. It does not accept the unpadded spelling (`50-06`, `49-W52`): nothing writes it after this change. - **`service-analytics` `bucketKeyAtOrdinal` (the declared cross-lane line).** It spelled every granularity itself and carried a private copy of the ISO week rule (`isoWeekKeyOfUtcMs`, deleted). It now computes only the UTC instant the ordinal's bucket starts at, and core's `bucketDateKey` spells the key (a local `bucketKeyAt`, which `calendarDayAt` now delegates to). No second padding. ## Before and after (function level) Core at base `c90f9fb6e2`, read from a temporary copy of the base `datetime.ts`, against this branch: | instant | base | this branch | |:--|:--|:--| | `0050-06-15T10:00Z` | `50` · `50-Q2` · `50-06` · `50-06-15` · `50-W24` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` · `0050-W24` | | `0050-01-01T10:00Z` | `50` · `50-Q1` · `50-01` · `50-01-01` · `49-W52` | `0050` · `0050-Q1` · `0050-01` · `0050-01-01` · `0049-W52` | | `0999-06-15T10:00Z` | `999` · `999-Q2` · `999-06` · `999-06-15` · `999-W24` | `0999` · `0999-Q2` · `0999-06` · `0999-06-15` · `0999-W24` | | `2026-06-15T10:00Z` (control) | `2026` · `2026-Q2` · `2026-06` · `2026-06-15` · `2026-W25` | identical | `bucketKeyToCalendarRange(key, 'week')`: `0050-W01`, `0049-W52` and `0999-W24` answered `null` at base. They now answer `0050-01-03`..`0050-01-10`, `0049-12-27`..`0050-01-03` and `0999-06-10`..`0999-06-17`. `2026-W01` answers `2025-12-29`..`2026-01-05` on both. ## The zone-2 hypotheses - **H1, the callers: held, and the census found two more writers.** Each named caller follows the helper with no edit: - `objectql` `in-memory-aggregation.ts` `bucketDateValue` is a thin delegate (pinned); - `objectql` `having-filter.ts` names the helper in a comment only; its `day` bucket's `date` class now holds a real `YYYY-MM-DD`; - `driver-memory` `memory-analytics.ts` `aggregateWithTimeBuckets` delegates (pinned); - `service-analytics` `analytics-service.ts`, the drill ranges, calls `bucketKeyToCalendarRange` (pinned through `queryDataset`); - `service-analytics` `dataset-executor.ts`: `calendarDayAt` delegates, `alignedCompareBucketKey` reads through the reader (pinned), and `bucketKeyAtOrdinal` is H2; - `core` `compensated-sum.ts` names the helper in a comment only. Two `service-analytics` writers call no helper and spell the year unpadded: `preview-evaluator.ts` `bucketDate` and `dimension-labels.ts` `formatDateBucket`. Both are outside the declared surface (Acceptance notes). - **H2: held.** `bucketKeyAtOrdinal` built its own keys at every granularity. It now calls the helper (above). - **H3: SQLite and PostgreSQL pad, measured; MySQL is NOT MEASURED.** - SQLite 3.53.4: pinned below. The keys equal `bucketDateKey` at `year`, `quarter`, `month` and `day` through a `datetime` and a `date` column. SQLite buckets `week` in memory, not in SQL. - PostgreSQL 16.13: measured live on a throwaway local server. This branch's `SqlDriver` ran `initObjects`, `create` and `aggregate` over a `datetime` and a `date` column holding 0050-06-15, 0050-01-01, 0999-06-15 and 2026-06-15. The keys equal `bucketDateKey` at all five granularities, 0 mismatches in 10 cells, `0049-W52` from `IYYY"-W"IW` included. This was a scratch harness, not committed (Acceptance notes). - MySQL: NOT MEASURED. This container has no MySQL server (no binary, no Docker daemon). The reference manual gives `%Y` and `%x` as four digits. - No dialect was found whose bucket SQL does not pad, and no driver changes. - **H4: read at function level, not reached, and no refusal added.** - Year 0 keys `0000`, as SQLite's `strftime('%Y')` does. - Year −1 keys `-1`, never the padded fragment `00-1`. SQLite answers `-001`. - Year 10000 keys `10000`. SQLite answers NULL. - The reader answers `null` for both the −1 and the 10000 key. - Both engine doors refuse these years for a `date` and a `datetime` value. The shape is pinned in the core file below. - **H5: held.** Early January 0050 keys `0049-W52`, and `0049-W52` spans `0049-12-27`..`0050-01-03`. This is pinned in core, objectql, service-analytics and the PostgreSQL reading. ## Pins One new file beside each face. Every expected key is spelled literally, and each file covers 0050, 0999 and the 2026 control: - core `datetime-bucket-key-four-digit-year.test.ts`: - the writer at every granularity, for 0001, 0050, 0999 and 2026, as a `Date` and as epoch ms too; - the Asia/Shanghai week-year; - the reader round-tripping every written key; - the week ranges above (`0050-W01` included); - the unpadded spelling answering `null`; - H4. - objectql `in-memory-aggregation-four-digit-year.test.ts`: the engine's in-memory `groupBy` at every granularity. - driver-memory `memory-analytics-four-digit-year.test.ts`: the memory cube face at every granularity. - driver-sql `sql-driver-bucket-key-four-digit-year.test.ts`: `SqlDriver` on SQLite against core's `bucketDateKey`, for 0050-06-15. - service-analytics `bucket-key-four-digit-year.test.ts`: - `bucketKeyAtOrdinal` against the grouped key at every granularity; - `alignedCompareBucketKey` restating `0049-06` / `0049-W24` as `0050-06` / `0050-W24`; - a `queryDataset` drill-down from `0050-W01` finding `0050-01-03`..`0050-01-10`. Two existing files had comments stating the unpadded spelling as current (`datetime-year-below-100.test.ts` in core, `week-key-year-below-100.test.ts` in service-analytics). Their comments are corrected, and their lenient readers now require the four-digit year. ## Ablation: red/green on the padding At `3716880ded`. The padding line is byte-identical at the PR head. - **Mutation.** `scripts/ablation-replace.mjs` turned `return year >= 0 ? String(year).padStart(4, '0') : String(year);` into `return String(year);` (anchor 1 → 0, blob `fe67aac16ed5` → `5a14574b2faf`). - **It reached `dist/`.** Core was rebuilt, and `ablation-dist-preflight --absent` passed: the marker was absent from all 14 built files. objectql and service-analytics read core from `dist/`. - **Red:** - core `datetime*`: 101 failed / 151 passed of 252; - objectql: 6 of 6 failed; - driver-memory: 5 of 5 failed; - driver-sql: 4 failed / 8 passed. The 8 are the SQL cells, green because SQLite pads on its own; only the in-memory cells go red; - service-analytics: 30 failed / 102 passed of 132. - **Restore.** The blob equals HEAD's `fe67aac16ed5` and `git diff HEAD` is empty. Core was rebuilt, and preflight in default mode found the marker in 2 built files with the tree clean. - **Green:** 252 / 6 / 5 / 12 / 132. ## Verification (head `c03977051a`) `main` at `05a7547c9f` (#20843) was merged in. It edits one comment in `datetime.ts`, and this branch's two range sentences now state its `datetime` floor. - **Suites** (`vitest run`, at `076ba0c599`, after the merge and a rebuild of the touched closure): - core: `local` 61 files / 1793 passed, `repo` 3 / 48; - objectql: `local` 345 / 6779; - driver-memory: 66 / 1475; - driver-sql: 202 passed, 11 skipped (the live cells, which have no URL here) / 3266 tests; - service-analytics: 142 / 3282. - **At `c03977051a`,** which changes only the driver-sql pin: every face's pins plus driver-sql's `date-bucket` and `date-bucket-storage` suites pass, 36 / 252 / 6 / 5 / 57. - **Typecheck:** `typecheck` exits 0 for all five packages. `tsc --listFiles` shows each new test file in a compiled program. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 67 families at `c03977051a`, and all 67 ran there. 67 of 67 exited 0. `--ran` reconciles: 67 derived, 67 run, 0 NOT MEASURED, 0 unrun. - `check:dual-build-cjs-loads` and `check:type-check-debt` first exited 3 (PREREQUISITE NOT MET: only the touched closure was built). Both exited 0 after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (71/71 tasks). The seat corrected this bullet from `os-dev-report` 5912276902; the head is unchanged. - The first run caught one `as any` on the driver-sql pin's aggregate options (`check:query-options-erasure`, test surface 236 → 237). It was typed in `c03977051a`, and the ratchet holds at 236. - **Lint (narrowed, proven).** `eslint --no-inline-config --format json` ran over the 9 changed `.ts` files: 9 files, 0 errors, 0 warnings, none reported as ignored. The config covers all 9. It enables no type-aware linting (no `parserOptions.project` anywhere, stated in `eslint.config.mjs` itself), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Clause-② measured Six published outputs change spelling for a year below 1000: - `bucketDateKey`; - the in-memory `groupBy` keys; - the memory cube labels; - the `compareTo` merge keys; - `bucketKeyToCalendarRange`, which now answers for a padded week key; - the display label `formatDateBucket` gives an in-memory month or day key: it was `1950-06`, a wrong century, and is now `50-06`, what it gives the SQL key. Each is now what the SQL path answered already: SQLite pinned, PostgreSQL measured. The reader drops no key it read before, because the unpadded spelling never matched its four-digit patterns. So `Clause-②: no` stands. ## Acceptance notes - **`dimension-labels.ts` `formatDateBucket` (service-analytics).** It relabels a date dimension's rows when labels resolve. For a padded key below 1000 it answers `year` `0050` → `1970`, reading a pure-digit key as epoch seconds because its year check admits only 1000..9999. It answers `month` `0050-06` → `50-06` and `day` `0050-06-15` → `50-06-15`. Function level. It is the same unpadded-year family as #20602, it sits outside the declared surface, and it is reported for the family card. - **`preview-evaluator.ts` `bucketDate` (service-analytics, the draft preview).** It spells the year unpadded at `year` / `quarter` / `month` / `day`. Its `week` key is the Monday's `YYYY-MM-DD` (pinned so), not the ISO label. Outside the surface. - **`driver-mongodb` `mongodb-aggregation.ts`.** A header comment says a year before 1000 "labels '0999' here and '999' in memory". This PR makes it false. The file is outside the declared surface, so it is left for its next editor. - **`@objectstack/verify` `checkDateBucketParity`.** It probes 2024 and 2025 instants only, so the parity device cannot see this family on any driver. No gate change here. - **The live dialect matrix (driver-sql `live-dialect-matrix.testkit.ts`).** It is not extended to this pin. A MySQL `datetime` below 1000 is #20280's ground, and its reading is not measured here. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f10d802 commit 856321f

10 files changed

Lines changed: 532 additions & 63 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/service-analytics': patch
4+
---
5+
6+
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.
7+
8+
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.
9+
10+
- **`@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.
11+
- **`@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`.
12+
- **`@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`.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20760] A bucket key spells its year with four digits at every granularity,
4+
// as the drivers' bucket expressions do (`strftime('%Y')` on SQLite), and
5+
// `bucketKeyToCalendarRange` reads exactly what `bucketDateKey` writes.
6+
//
7+
// A `date` value keeps the years 0001..9999, so a key in 0001..0999 is
8+
// reachable through a `date` field and through a stored `datetime` row. Before
9+
// this card the writer spelled such a year unpadded (`50-06`, `49-W52`) while
10+
// SQL answered `0050-06`, and the reader's week arm checked a padded key
11+
// against the unpadded label, so `0050-W01` found no range.
12+
//
13+
// Pins: 0001, 0050 and 0999 at every granularity, the ISO week-year at a year
14+
// boundary (early January 0050 is in `0049-W52`), a drill-down from
15+
// `0050-W01`, and a 2026 control. Every expected key is spelled literally,
16+
// never computed by the code under test.
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { bucketDateKey, bucketKeyToCalendarRange, type BucketGranularity } from './datetime.js';
20+
21+
const GRANULARITIES = ['year', 'quarter', 'month', 'day', 'week'] as const satisfies readonly BucketGranularity[];
22+
23+
/** instant → the key at year / quarter / month / day / week. */
24+
const KEYS: ReadonlyArray<readonly [string, Record<BucketGranularity, string>]> = [
25+
['0001-01-01T10:00:00.000Z', { year: '0001', quarter: '0001-Q1', month: '0001-01', day: '0001-01-01', week: '0001-W01' }],
26+
['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15', week: '0050-W24' }],
27+
// The ISO week-numbering year is the previous calendar year here.
28+
['0050-01-01T10:00:00.000Z', { year: '0050', quarter: '0050-Q1', month: '0050-01', day: '0050-01-01', week: '0049-W52' }],
29+
['0999-06-15T10:00:00.000Z', { year: '0999', quarter: '0999-Q2', month: '0999-06', day: '0999-06-15', week: '0999-W24' }],
30+
// The control: a four-digit year is spelled as it always was.
31+
['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', day: '2026-06-15', week: '2026-W25' }],
32+
];
33+
34+
describe('[#20760] bucketDateKey spells the year with four digits', () => {
35+
describe.each(KEYS)('%s', (instant, expected) => {
36+
it.each(GRANULARITIES)('at %s', (g) => {
37+
expect(bucketDateKey(instant, g)).toBe(expected[g]);
38+
// A `Date` and epoch milliseconds name the same instant and get the same key.
39+
expect(bucketDateKey(new Date(instant), g)).toBe(expected[g]);
40+
expect(bucketDateKey(Date.parse(instant), g)).toBe(expected[g]);
41+
});
42+
});
43+
44+
it('a `date` value, the bare `YYYY-MM-DD` form, keys like its midnight', () => {
45+
expect(bucketDateKey('0050-06-15', 'day')).toBe('0050-06-15');
46+
expect(bucketDateKey('0050-06-15', 'month')).toBe('0050-06');
47+
expect(bucketDateKey('0999-12-31', 'year')).toBe('0999');
48+
});
49+
50+
it('the week year of a reference zone is four digits too', () => {
51+
// Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai: week 1 of 0050.
52+
const instant = '0050-01-02T20:00:00.000Z';
53+
expect(bucketDateKey(instant, 'week')).toBe('0049-W52');
54+
expect(bucketDateKey(instant, 'week', 'Asia/Shanghai')).toBe('0050-W01');
55+
expect(bucketDateKey(instant, 'day', 'Asia/Shanghai')).toBe('0050-01-03');
56+
});
57+
});
58+
59+
describe('[#20760] bucketKeyToCalendarRange reads exactly what bucketDateKey writes', () => {
60+
describe.each(KEYS)('the keys of %s', (instant, expected) => {
61+
it.each(GRANULARITIES)('at %s span a range whose first day keys back to it', (g) => {
62+
const range = bucketKeyToCalendarRange(expected[g], g);
63+
expect(range).not.toBeNull();
64+
expect(bucketDateKey(range!.start, g)).toBe(expected[g]);
65+
expect(range!.start <= instant.slice(0, 10) && instant.slice(0, 10) < range!.end).toBe(true);
66+
});
67+
});
68+
69+
it.each([
70+
// A drill-down from the padded week key a SQL driver answers.
71+
['0050-W01', '0050-01-03', '0050-01-10'],
72+
// The ISO week-year boundary: the week runs into January 0050.
73+
['0049-W52', '0049-12-27', '0050-01-03'],
74+
['0999-W24', '0999-06-10', '0999-06-17'],
75+
// The control.
76+
['2026-W01', '2025-12-29', '2026-01-05'],
77+
])('week %s is %s up to %s', (key, start, end) => {
78+
expect(bucketKeyToCalendarRange(key, 'week')).toEqual({ start, end });
79+
});
80+
81+
it.each([
82+
['50', 'year'],
83+
['50-Q2', 'quarter'],
84+
['50-06', 'month'],
85+
['50-06-15', 'day'],
86+
['49-W52', 'week'],
87+
['999-W24', 'week'],
88+
] as const)('does not read the unpadded spelling %s (%s): nothing writes it', (key, g) => {
89+
expect(bucketKeyToCalendarRange(key, g)).toBeNull();
90+
});
91+
});
92+
93+
describe('[#20760] a year outside 0000..9999 has no four-digit form, and is not padded into one', () => {
94+
// Not reached: at both engine doors a `date` value names a year from 0001 to
95+
// 9999 and a `datetime` one a year from 1000 to 9999. Stated so a negative
96+
// year is never spelled as a padded fragment (`00-1`) that reads as a key.
97+
const inYear = (year: number) => {
98+
const d = new Date(0);
99+
d.setUTCFullYear(year, 5, 15);
100+
return d;
101+
};
102+
103+
it.each([
104+
[-1, '-1', '-1-06-15'],
105+
[10000, '10000', '10000-06-15'],
106+
])('year %i keys as %s and %s, and no range reads it', (year, yearKey, dayKey) => {
107+
expect(bucketDateKey(inYear(year), 'year')).toBe(yearKey);
108+
expect(bucketDateKey(inYear(year), 'day')).toBe(dayKey);
109+
expect(bucketKeyToCalendarRange(yearKey, 'year')).toBeNull();
110+
expect(bucketKeyToCalendarRange(dayKey, 'day')).toBeNull();
111+
});
112+
});

‎packages/core/src/utils/datetime-year-below-100.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,12 @@ describe.each(HOSTS)('on a %s host', (host) => {
158158
});
159159

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

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

245-
// The week arm validates a reconstructed Monday against the week LABEL,
246-
// which is spelled unpadded below the year 1000 (the unpadded-key family,
247-
// left alone here), so it is pinned on the 2026 control only.
246+
// [#20760] The week arm's keys in 0001..0999 (`0050-W01`, `0049-W52`) are
247+
// pinned in `datetime-bucket-key-four-digit-year.test.ts`.
248248
it('week 2026-W01 (the control)', () => {
249249
expect(bucketKeyToCalendarRange('2026-W01', 'week')).toEqual({ start: '2025-12-29', end: '2026-01-05' });
250250
});

‎packages/core/src/utils/datetime.ts‎

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,13 @@ export function isBucketGranularity(value: unknown): value is BucketGranularity
302302
* Null and unparseable deliberately share one bucket: SQL cannot tell them apart
303303
* either (`strftime('%Y-%m', 'not-a-date')` is NULL), and splitting them here
304304
* would re-open the seam this function exists to close.
305+
*
306+
* [#20760] The year of every key is spelled with four digits
307+
* ({@link bucketKeyYear}): `0050`, `0050-Q2`, `0050-06`, `0050-06-15`,
308+
* `0049-W52` — what the drivers' bucket expressions answer for the same
309+
* instant. A `date` value keeps the years 0001..9999, and a `datetime` row
310+
* stored before the engine doors refused a year below 1000 can still hold one,
311+
* so 0001..0999 reach this function.
305312
*/
306313
export function bucketDateKey(
307314
value: unknown,
@@ -319,13 +326,13 @@ export function bucketDateKey(
319326
const { year: y, month: m, day } = calendarPartsInTzOrUtc(d, timezone);
320327
switch (granularity) {
321328
case 'year':
322-
return String(y);
329+
return bucketKeyYear(y);
323330
case 'quarter':
324-
return `${y}-Q${Math.floor((m - 1) / 3) + 1}`;
331+
return `${bucketKeyYear(y)}-Q${Math.floor((m - 1) / 3) + 1}`;
325332
case 'month':
326-
return `${y}-${String(m).padStart(2, '0')}`;
333+
return `${bucketKeyYear(y)}-${String(m).padStart(2, '0')}`;
327334
case 'day':
328-
return `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
335+
return bucketDayKey(y, m, day);
329336
case 'week':
330337
return isoWeekLabelFromCalendarDay(y, m, day);
331338
default:
@@ -337,6 +344,33 @@ export function bucketDateKey(
337344
}
338345
}
339346

347+
/**
348+
* [#20760] The year of a bucket key, spelled with four digits: `50` is
349+
* `0050`, `999` is `0999`, `2026` is `2026`.
350+
*
351+
* The ONE statement of the key's year spelling. Every key
352+
* {@link bucketDateKey} writes, the ISO week label, and the calendar bounds
353+
* {@link bucketKeyToCalendarRange} answers spell their year through it, so
354+
* the writer and the reader cannot disagree about it. It is the width the
355+
* drivers' bucket expressions answer (`strftime('%Y')` on SQLite, `YYYY` /
356+
* `IYYY` in PostgreSQL's `to_char`, `%Y` / `%x` in MySQL's `date_format`),
357+
* and `bucketDateKey`'s contract is that its label equals theirs.
358+
*
359+
* A year below 0 has no four-digit form and keeps its plain spelling (`-1`),
360+
* never a padded fragment such as `00-1`; a year past 9999 is longer than four
361+
* digits already. Neither is reached: at both engine doors a `date` value
362+
* names a year from 0001 to 9999 and a `datetime` one a year from 1000 to
363+
* 9999, and {@link bucketKeyToCalendarRange} reads neither spelling.
364+
*/
365+
function bucketKeyYear(year: number): string {
366+
return year >= 0 ? String(year).padStart(4, '0') : String(year);
367+
}
368+
369+
/** [#20760] The `day` key (`YYYY-MM-DD`) of a calendar day, `month` 1-12. */
370+
function bucketDayKey(year: number, month: number, day: number): string {
371+
return `${bucketKeyYear(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
372+
}
373+
340374
/**
341375
* ISO-8601 week label (Mon-start weeks, week 1 = the week of the first
342376
* Thursday) of a calendar day given that day's parts (`month` is 1-12).
@@ -347,6 +381,10 @@ export function bucketDateKey(
347381
*
348382
* [#20599] Both days are built by {@link wallClockToUtcMs}, so a day in
349383
* 0001..0099 lands in its own ISO week, never in the 1900s one.
384+
*
385+
* [#20760] The label's year is the ISO week-numbering year, spelled with four
386+
* digits ({@link bucketKeyYear}). Early in January it can be the previous
387+
* calendar year: 0050-01-01 is in `0049-W52`.
350388
*/
351389
function isoWeekLabelFromCalendarDay(year: number, month: number, day: number): string {
352390
const target = new Date(wallClockToUtcMs({ year, month, day }));
@@ -361,7 +399,7 @@ function isoWeekLabelFromCalendarDay(year: number, month: number, day: number):
361399
((firstThursday.getUTCDay() + 6) % 7)) /
362400
7,
363401
);
364-
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
402+
return `${bucketKeyYear(target.getUTCFullYear())}-W${String(weekNo).padStart(2, '0')}`;
365403
}
366404

367405
/**
@@ -398,17 +436,20 @@ function isoWeekLabelUtc(d: Date): string {
398436
* never the 1900s one. The arms lean on its rollover, which is `Date.UTC`'s:
399437
* month 13 is next January (Q4's and December's end), and day 32 the next
400438
* month.
439+
*
440+
* [#20760] It reads exactly what {@link bucketDateKey} writes: a four-digit
441+
* year at every granularity, the week key included (`0050-W01`). The day and
442+
* week arms check a key against the label the writer gives the reconstructed
443+
* day, and every bound is spelled by the writer's own day key, so the reader
444+
* cannot drift from the writer. An unpadded key (`50-06`, `49-W52`) is not a
445+
* bucket key and answers `null`.
401446
*/
402447
export function bucketKeyToCalendarRange(
403448
key: string | null | undefined,
404449
granularity: BucketGranularity,
405450
): { start: string; end: string } | null {
406451
if (typeof key !== 'string' || key.length === 0) return null;
407-
const fmt = (dt: Date) =>
408-
`${String(dt.getUTCFullYear()).padStart(4, '0')}-${String(dt.getUTCMonth() + 1).padStart(
409-
2,
410-
'0',
411-
)}-${String(dt.getUTCDate()).padStart(2, '0')}`;
452+
const fmt = (dt: Date) => bucketDayKey(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate());
412453
/** Midnight UTC of `year`-`month`-`day`, `month` 1-12, rolled over past its end. */
413454
const utcDay = (year: number, month: number, day: number) =>
414455
new Date(wallClockToUtcMs({ year, month, day }));
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20760] The memory cube face labels a time bucket in 0001..0999 with a
4+
// four-digit year — `0050`, `0050-Q2`, `0050-06`, `0050-06-15`, `0050-W24` —
5+
// the key a SQL driver's bucket expression answers for the same instant. It
6+
// folds the key through `@objectstack/core`'s `bucketDateKey`; this pins the
7+
// face. Before the card it answered `50`, `50-Q2`, `50-06`, `50-06-15` and
8+
// `50-W24`, and a drill-down from such a key found no range.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { InMemoryDriver } from './memory-driver.js';
12+
import { MemoryAnalyticsService } from './memory-analytics.js';
13+
import { AnalyticsQuerySchema } from '@objectstack/spec/data';
14+
import type { AnalyticsQuery, Cube } from '@objectstack/spec/data';
15+
16+
const cubes: Cube[] = [
17+
{
18+
name: 'events',
19+
title: 'Events',
20+
sql: 'events',
21+
measures: { count: { label: 'Event Count', type: 'count', sql: 'id' } },
22+
dimensions: {
23+
createdAt: {
24+
label: 'Created At',
25+
type: 'time',
26+
sql: 'created_at',
27+
granularities: ['day', 'week', 'month', 'quarter', 'year'],
28+
},
29+
},
30+
},
31+
];
32+
33+
const ROWS = [
34+
{ id: 1, created_at: '0050-06-15T10:00:00.000Z' },
35+
{ id: 2, created_at: '0999-06-15T10:00:00.000Z' },
36+
// The control.
37+
{ id: 3, created_at: '2026-06-15T10:00:00.000Z' },
38+
];
39+
40+
async function labels(granularity: 'day' | 'week' | 'month' | 'quarter' | 'year'): Promise<unknown[]> {
41+
const driver = new InMemoryDriver({ initialData: { events: ROWS } });
42+
await driver.connect();
43+
const service = new MemoryAnalyticsService({ driver, cubes });
44+
const result = await service.query(
45+
AnalyticsQuerySchema.parse({
46+
cube: 'events',
47+
measures: ['events.count'],
48+
dimensions: ['events.createdAt'],
49+
timeDimensions: [{ dimension: 'events.createdAt', granularity }],
50+
} satisfies AnalyticsQuery),
51+
);
52+
return result.rows.map((r) => r['events.createdAt']).sort();
53+
}
54+
55+
describe('[#20760] the memory cube face labels a year below 1000 with four digits', () => {
56+
it.each([
57+
['year', ['0050', '0999', '2026']],
58+
['quarter', ['0050-Q2', '0999-Q2', '2026-Q2']],
59+
['month', ['0050-06', '0999-06', '2026-06']],
60+
['day', ['0050-06-15', '0999-06-15', '2026-06-15']],
61+
['week', ['0050-W24', '0999-W24', '2026-W25']],
62+
] as const)('at %s', async (granularity, expected) => {
63+
expect(await labels(granularity)).toEqual(expected);
64+
});
65+
});

0 commit comments

Comments
 (0)