Skip to content

Commit 3a89d45

Browse files
fix(spec): nextUtcCalendarDay and utcInstantMs read years 0001..0099 as written, not as 1900..1999 (#20550) (#20591)
Fixes #20550 Clause-②: no `nextUtcCalendarDay` (`packages/spec/src/data/calendar-day.ts`) proves a bare `YYYY-MM-DD` is a real day by building it and reading it back. It built the date with `Date.UTC(y, mo - 1, d)`, and `Date.UTC` reads a year from 0 to 99 as 1900 + year, so `0050-01-01` was built as `1950-01-01`, the round trip failed, and the helper answered `null` for every day of the years 0001..0099. `utcInstantMs` asks the same helper about a bare day, so it answered `null` for them too. A `datetime` `$lte` or `$between` maximum on such a day then skipped whole-day widening (ADR-0053 D-D) and stopped at the day's first instant. The accept set is unchanged: a query on years 0001..0099 now answers what the contract already says (the claim's `Clause-②: no`). ## The change - `packages/spec/src/data/calendar-day.ts`: a private `utcMidnight(year, monthIndex, day)` builds the date as `new Date(0)` plus `setUTCFullYear(year, monthIndex, day)`, which takes the year as written and rolls the month and day over exactly as `Date.UTC` does. `nextUtcCalendarDay` uses it for both the round trip and the next day. The impossible-day refusal is unchanged in kind: `2026-02-30`, `0050-02-30` and `0100-02-29` still roll over and are refused by the round trip. - `utcInstantMs` is byte-identical. Its bare-day arm already delegated the "is this a real day" question to `nextUtcCalendarDay` and then read the instant with `Date.parse` of ISO text, which reads year 0050 correctly; its timestamp arm never touched `Date.UTC`. So the one construction fixes both helpers. No other helper in the file builds a date. - No edit under `packages/core/**` or `packages/drivers/**`: every caller imports these helpers from `@objectstack/spec` (core re-exports them). ## Verification (final head `6a5dce7378`, base `f11b5f20a2`) Premise, measured before editing: 1. **Helper, on the base.** Instrument: `tsx` importing `packages/spec/src/data/calendar-day.ts` at `f11b5f20a2`. `nextUtcCalendarDay('0050-01-01')` = `null`, `utcInstantMs('0050-01-01')` = `null`; the same for `0001-01-01` and `0099-12-31`. Control: `'0100-01-01'` answers `'0100-01-02'` and `-59011459200000`. 2. **Exhaustive, base against head.** Instrument: every string `YYYY-MM-DD` with year 0000..9999, month 00..13 and day 00..32 (4,620,000 strings, 3,652,425 real days), compared with a pure-arithmetic proleptic Gregorian oracle that uses no `Date`. Base: 36,525 mismatches for each helper, exactly every real day of the years 0000..0099. Head: 0 mismatches for either helper. 3. **Over REST, on the base semantics.** Instrument: the new `packages/rest/src/data-query-calendar-day-year-below-100.test.ts` through `POST /api/v1/data/:object/query`, process in `America/New_York`, with `calendar-day.ts`'s construction put back to `Date.UTC` (reverse verification below). SQLite and PostgreSQL 16 answered the same: | `where opened_at` | base (SQLite, PostgreSQL) | head (SQLite, PostgreSQL) | |:--|:--|:--| | `$lte '0050-01-01'` | `y49` | `y49`, `y50`, `y50_last` | | `$between ['0050-01-01', '0050-01-01']` | none | `y50`, `y50_last` | | `$lte '2026-07-15'` (control) | `c26`, `y49`, `y50`, `y50_last`, `y50_next` | the same | | `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | the same | Rows: `y49` = `0049-12-31T10:00Z`, `y50` = `0050-01-01T10:00Z` (the card's row), `y50_last` = `0050-01-01T23:59:59.999Z`, `y50_next` = `0050-01-02T00:00Z`, `c26` = `2026-07-15T14:00Z` (the card's control), `c26_next` = `2026-07-16T00:00Z`. The next day's midnight stays out in every cell. Reverse verification (fix committed first, mutation through `scripts/ablation-replace.mjs`, `trap` restore to the `HEAD` blob): the `utcMidnight` body was replaced by `return new Date(Date.UTC(year, monthIndex, day));`; the anchor fell 1 to 0 and the replacement rose 0 to 1 on disk; `pnpm --filter @objectstack/spec build`; `scripts/ablation-dist-preflight.mjs` found the mutation in 4 built files and the fix's line in none. Then `calendar-day.test.ts`: 3 failed / 7 passed (`0001-01-01: expected null to be '0001-01-02'`); the REST file: 2 failed / 2 passed, the table's base column, identical on both cells. Restore: blob `f03557d5bb` equals `HEAD`, whole-tree `git status --porcelain` empty, a full spec rebuild, the preflight found the fix in 4 built files and the mutation in none, and both files went green again (10/10, 4/4). Tests at `6a5dce7378`: - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: 574 files, 16,883 passed, 1 todo. - `@objectstack/rest`, the new file with the existing temporal suites (`data-temporal-write-real-day-iso`, `data-temporal-year-range`, `data-query-date-year-range`, `data-date-read-year-below-100`, `data-date-write-iso-only`, `data-query-epoch-ms-date-comparand`, `data-query-having-temporal-door`, `aggregation-filter-temporal-storage-rule`, `rest-14078-invalid-date-total-arm`), with a private PostgreSQL 16 server (zone `Asia/Shanghai`) as `OS_TEST_POSTGRES_URL`: 10 files, 89 passed, 5 skipped. The 5 skips are MySQL cells (`OS_TEST_MYSQL_URL` unset). The new file ran on SQLite and PostgreSQL, the two drivers the PR #20547 harness runs. - `driver-sql` `sql-driver-calendar-day-upper-bound.test.ts` (SQLite and PostgreSQL ran, MySQL not run): 15 passed. `driver-memory`: the six `memory-analytics-date-range-*` suites and `memory-driver-calendar-day-upper-bound.test.ts` (run, not edited): 7 files, 103 passed. - `pnpm --filter @objectstack/spec typecheck` and `pnpm --filter @objectstack/rest typecheck`: exit 0; `tsc --listFiles` over `packages/rest/tsconfig.test.json` includes the new test file. - Lint, narrowed: `eslint --no-inline-config --format json` over the three touched `.ts` files: 3 files, 0 errors, 0 warnings. `eslint --print-config` resolves a config for each of them, and `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so the diff cannot move any untouched file's verdict. - Gates from `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 83 commands, 81 exit 0. Two were NOT MEASURED, `PREREQUISITE NOT MET` (exit 3), because they need the whole workspace built: `pnpm check:dual-build-cjs-loads` (44 packages without `dist/`) and `pnpm check:type-check-debt` (7 dependencies without a built type entry). CI runs both. Full-repo pin sweep: no test asserts `null` from either helper for a year below 100 (`git grep` for both names applied to a `00NN-` literal: 0 hits), and no suite asserts a `$lte` / `$between` / date-range upper bound on a `datetime` field in those years. So no pin had to change. ## Acceptance notes - **Year 0000.** The new construction also answers for year 0000 (`0000-12-31` is followed by `0001-01-01`), where the base answered `null`. It is not pinned: 0001..9999 is the supported range, and `@objectstack/core`'s `isOutsideTemporalYearRange` refuses year 0000 at the comparand and write doors before either helper is asked. The helper does not keep a second copy of that range. - **Memory driver**, through the engine at head (not REST; `@objectstack/driver-memory` has no binding in `packages/rest`): `$lte '0050-01-01'` answers `y49`, `y50`; the `$between` maximum answers `y50`; the 2026 controls are unchanged. - **Out of scope, reported to the seat and not changed here:** other `Date.UTC` constructions read an author-given year below 100 as 1900 + year, in `packages/core/src/utils/datetime.ts` and `packages/rest/src/import-coerce.ts`. For example, the import door stores a CSV `datetime` cell `0050-01-01 10:00` as `1950-01-01T10:00:00.000Z`. Separately, `nextUtcCalendarDay('9999-12-31')` answers `'10000-01-01'`, so on SQLite a `datetime` `$lte '9999-12-31'` answers no rows. That is unchanged by this PR and measured in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92fe081 commit 3a89d45

4 files changed

Lines changed: 256 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): `nextUtcCalendarDay` and `utcInstantMs` read a bare day in the years 0001..0099 as written, not as 1900..1999
6+
7+
`nextUtcCalendarDay` proves a bare `YYYY-MM-DD` is a real day by building it and reading it back. It built the date with `Date.UTC`, which reads a year from 0 to 99 as 1900 + year, so `0050-01-01` came back as `1950-01-01`, the round trip failed, and the helper answered `null` for every day of those years. `utcInstantMs` asks the same round trip about a bare day, so it answered `null` for them too. The date is now built with `setUTCFullYear`, which takes the year as written; an impossible day (`0050-02-30`, `0100-02-29`) is still refused, not rolled over.
8+
9+
What an author sees: a `datetime` filter `$lte '0050-01-01'`, or a `$between` whose maximum is that day, now includes the whole day, as it already did for `'2026-07-15'`. Before, it stopped at the day's first instant, so a row stored at `0050-01-01T10:00:00.000Z` was missed. Measured through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16: `$lte '0050-01-01'` answered only the row of `0049-12-31` and now also answers the two rows of `0050-01-01`; `$between ['0050-01-01', '0050-01-01']` answered no rows and now answers both. The next day's midnight stays out, and the 2026 control answers the same before and after. The other callers of the two helpers (the memory and mongo drivers, the analytics strategies, the engine's `having` filter and `formula`'s RLS `check` evaluator) import them from this package, so the correction reaches them with no change of their own.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20550] A bare `YYYY-MM-DD` upper bound on a `datetime` field — `$lte`, or
5+
* a `$between` maximum — includes the whole of that day in the years
6+
* 0001..0099 exactly as it does in 2026, at the public door
7+
* `POST /api/v1/data/:object/query`, over a real `SqlDriver`.
8+
*
9+
* The widening (ADR-0053 D-D) compiles `$lte day` to `< nextUtcCalendarDay(day)`
10+
* midnight UTC, and falls back to `<= day` midnight when the helper answers
11+
* `null`. `@objectstack/spec`'s `nextUtcCalendarDay` built the date through
12+
* `Date.UTC`, which reads a year from 0 to 99 as 1900 + year, so the round trip
13+
* that proves a day real failed for every day of those years and the bound
14+
* stopped at the day's first instant. Measured through this door on the base,
15+
* the process in America/New_York, on the six rows below:
16+
*
17+
* | `where opened_at` | SQLite | PostgreSQL 16 | the whole day |
18+
* |:--|:--|:--|:--|
19+
* | `$lte '0050-01-01'` | `y49` | `y49` | `y49`, `y50`, `y50_last` |
20+
* | `$between ['0050-01-01', '0050-01-01']` | none | none | `y50`, `y50_last` |
21+
* | `$lte '2026-07-15'` (control) | the whole day | the whole day | `y49` … `y50_next`, `c26` |
22+
* | `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | `c26` | `c26` |
23+
*
24+
* The next day's midnight (`y50_next`, `c26_next`) stays out in every cell: the
25+
* bound is half-open, never an inclusive `23:59:59.999`.
26+
*
27+
* ## The dialect axis of THIS file
28+
*
29+
* The SQLite cell always runs. The PostgreSQL cell runs where
30+
* `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI job
31+
* provisions it for this package. It owns one table, dropped before and after.
32+
* An in-memory cell is not here, for the reason
33+
* `data-temporal-write-real-day-iso.test.ts` gives: `@objectstack/driver-memory`
34+
* has no binding in this package. The helper's own pins, every year edge and
35+
* the impossible days included, are `packages/spec/src/data/calendar-day.test.ts`.
36+
*/
37+
38+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
39+
import { ObjectQL } from '@objectstack/objectql';
40+
import { SqlDriver } from '@objectstack/driver-sql';
41+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
42+
import { RestServer } from './rest-server';
43+
44+
const OBJECT = 'rest_calendar_day_20550';
45+
const HOST_ZONE = 'America/New_York';
46+
47+
const LEDGER = {
48+
name: OBJECT,
49+
label: 'Ledger 20550',
50+
fields: {
51+
memo: { name: 'memo', type: 'text' as const },
52+
opened_at: { name: 'opened_at', type: 'datetime' as const },
53+
},
54+
};
55+
56+
/** id · the instant written through the door. */
57+
const ROWS: ReadonlyArray<readonly [string, string]> = [
58+
['y49', '0049-12-31T10:00:00.000Z'],
59+
['y50', '0050-01-01T10:00:00.000Z'], // the card's row
60+
['y50_last', '0050-01-01T23:59:59.999Z'], // the day's last millisecond
61+
['y50_next', '0050-01-02T00:00:00.000Z'], // the next day's first instant: out
62+
['c26', '2026-07-15T14:00:00.000Z'], // the card's control row
63+
['c26_next', '2026-07-16T00:00:00.000Z'],
64+
];
65+
66+
/** `where` on `opened_at` · the ids it answers, sorted. */
67+
const QUERIES: ReadonlyArray<readonly [string, Record<string, unknown>, readonly string[]]> = [
68+
["$lte '0050-01-01'", { $lte: '0050-01-01' }, ['y49', 'y50', 'y50_last']],
69+
["$between max '0050-01-01'", { $between: ['0050-01-01', '0050-01-01'] }, ['y50', 'y50_last']],
70+
["$lte '2026-07-15' (control)", { $lte: '2026-07-15' }, ['c26', 'y49', 'y50', 'y50_last', 'y50_next']],
71+
["$between max '2026-07-15' (control)", { $between: ['2026-07-15', '2026-07-15'] }, ['c26']],
72+
];
73+
74+
interface Cell {
75+
id: 'sqlite' | 'pg';
76+
label: string;
77+
env: string | null;
78+
config: () => Record<string, unknown> | null;
79+
}
80+
81+
const CELLS: readonly Cell[] = [
82+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
83+
{
84+
id: 'pg',
85+
label: 'live postgres',
86+
env: 'OS_TEST_POSTGRES_URL',
87+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
88+
},
89+
];
90+
91+
function createMockServer() {
92+
const noop = () => {};
93+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
94+
}
95+
96+
function makeRes() {
97+
const res: any = {
98+
write: () => true, end: () => {},
99+
header: () => res,
100+
status: (code: number) => { res._status = code; return res; },
101+
json: (body: any) => { res._json = body; return res; },
102+
};
103+
return res;
104+
}
105+
106+
const originalTz = process.env.TZ;
107+
108+
for (const cell of CELLS) {
109+
const config = cell.config();
110+
describe.skipIf(!config)(
111+
`[#20550] a bare-day upper bound on a datetime includes the whole day in years 0001..0099, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
112+
() => {
113+
let engine: ObjectQL;
114+
let driver: any;
115+
let call: (method: string, path: string, params: Record<string, string>, body: unknown) => Promise<{ status: number; body: any }>;
116+
117+
beforeAll(async () => {
118+
// A host whose zone is not UTC, so a host-zone reading of a bound would show.
119+
process.env.TZ = HOST_ZONE;
120+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE);
121+
122+
driver = new SqlDriver(config as any);
123+
if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {});
124+
engine = new ObjectQL();
125+
engine.registerDriver(driver, true);
126+
await engine.init();
127+
engine.registry.registerObject(LEDGER as any);
128+
await engine.syncSchemas();
129+
130+
const protocol = new ObjectStackProtocolImplementation(engine as any);
131+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
132+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
133+
rest.registerRoutes();
134+
call = async (method, path, params, body) => {
135+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
136+
expect(route, `${method} ${path}`).toBeDefined();
137+
const res = makeRes();
138+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
139+
return { status: res._status ?? 200, body: res._json };
140+
};
141+
142+
for (const [id, openedAt] of ROWS) {
143+
const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, memo: 'm', opened_at: openedAt });
144+
expect(created.status, `create ${id} ${openedAt}: ${JSON.stringify(created.body)}`).toBe(201);
145+
}
146+
});
147+
148+
afterAll(async () => {
149+
if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
150+
try { await engine?.destroy(); } catch { /* noop */ }
151+
if (originalTz === undefined) delete process.env.TZ;
152+
else process.env.TZ = originalTz;
153+
});
154+
155+
const idsWhere = async (opened_at: Record<string, unknown>) => {
156+
const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { opened_at } });
157+
expect(res.status, JSON.stringify(res.body)).toBe(200);
158+
return (res.body.records as Array<{ id: string }>).map((r) => r.id).sort();
159+
};
160+
161+
it('every row reads back the instant it was written — the rows the bounds are measured against', async () => {
162+
const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { memo: 'm' } });
163+
expect(res.status, JSON.stringify(res.body)).toBe(200);
164+
const stored = Object.fromEntries((res.body.records as Array<{ id: string; opened_at: string }>).map((r) => [r.id, r.opened_at]));
165+
expect(stored).toEqual(Object.fromEntries(ROWS));
166+
});
167+
168+
it('$lte and the $between maximum on 0050-01-01 include that whole day, and not the next midnight — as the 2026 control does', async () => {
169+
// Every reading first, then one comparison, so a red run shows all four cells.
170+
const got: Record<string, string[]> = {};
171+
for (const [name, bound] of QUERIES) got[name] = await idsWhere(bound);
172+
expect(got).toEqual(Object.fromEntries(QUERIES.map(([name, , want]) => [name, want])));
173+
});
174+
},
175+
);
176+
}

‎packages/spec/src/data/calendar-day.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { describe, expect, it } from 'vitest';
12-
import { nextUtcCalendarDay } from './calendar-day';
12+
import { nextUtcCalendarDay, utcInstantMs } from './calendar-day';
1313

1414
describe('nextUtcCalendarDay', () => {
1515
it('advances one calendar day', () => {
@@ -59,3 +59,51 @@ describe('nextUtcCalendarDay', () => {
5959
expect(nextUtcCalendarDay(next)).toBe('2027-01-02');
6060
});
6161
});
62+
63+
/**
64+
* [#20550] Every day of the supported years 0001..9999 is a calendar day to
65+
* both helpers, the years 0001..0099 included. `Date.UTC` reads a year from 0
66+
* to 99 as 1900 + year, so a construction through it built `0050-01-01` as
67+
* 1950-01-01, the round trip failed, and both helpers answered `null` for
68+
* every day of those years: a `$lte` or a `$between` maximum on such a day
69+
* compiled to that day's midnight and missed the rest of it. Year 0100 is the
70+
* control that always answered, and 2026 the everyday one.
71+
*/
72+
describe('[#20550] years 0001..0099 are calendar days, not 1900..1999', () => {
73+
/** day · the day after it */
74+
const DAYS: ReadonlyArray<readonly [string, string]> = [
75+
['0001-01-01', '0001-01-02'],
76+
['0050-01-01', '0050-01-02'],
77+
['0099-12-31', '0100-01-01'], // the century rolls over, not back to 1900
78+
['0100-01-01', '0100-01-02'], // the control that answered before
79+
['2026-07-15', '2026-07-16'], // the everyday control
80+
];
81+
82+
it('nextUtcCalendarDay answers the day after, in the year as written', () => {
83+
for (const [day, next] of DAYS) expect(nextUtcCalendarDay(day), day).toBe(next);
84+
});
85+
86+
it("utcInstantMs answers that day's midnight UTC", () => {
87+
for (const [day] of DAYS) {
88+
const ms = utcInstantMs(day);
89+
expect(ms, day).toBe(Date.parse(`${day}T00:00:00.000Z`));
90+
expect(new Date(ms!).toISOString(), day).toBe(`${day}T00:00:00.000Z`);
91+
}
92+
});
93+
94+
it('an impossible day in those years is still refused by both, not rolled over', () => {
95+
for (const day of ['0050-02-30', '0001-13-01', '0099-04-31', '0100-02-29', '2026-02-30']) {
96+
expect(nextUtcCalendarDay(day), day).toBeNull();
97+
expect(utcInstantMs(day), day).toBeNull();
98+
}
99+
});
100+
101+
it('the leap rule is the proleptic Gregorian one in those years: 0004-02-29 is a day, 0100-02-29 is not', () => {
102+
expect(nextUtcCalendarDay('0004-02-28')).toBe('0004-02-29');
103+
expect(nextUtcCalendarDay('0004-02-29')).toBe('0004-03-01');
104+
expect(utcInstantMs('0004-02-29')).toBe(Date.parse('0004-02-29T00:00:00.000Z'));
105+
expect(nextUtcCalendarDay('0100-02-28')).toBe('0100-03-01'); // 0100 is not a leap year
106+
expect(nextUtcCalendarDay('0100-02-29')).toBeNull();
107+
expect(utcInstantMs('0100-02-29')).toBeNull();
108+
});
109+
});

‎packages/spec/src/data/calendar-day.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ export function nextUtcCalendarDay(value: unknown): string | null {
4545
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
4646
if (!m) return null;
4747
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
48-
const start = new Date(Date.UTC(y, mo - 1, d));
49-
// Reject a shape-valid but impossible day: `Date.UTC` rolls 2026-02-30 into
50-
// March, so the round-trip is what proves the input was a real calendar day.
48+
const start = utcMidnight(y, mo - 1, d);
49+
// Reject a shape-valid but impossible day: the construction rolls 2026-02-30
50+
// into March, so the round-trip is what proves the input was a real calendar
51+
// day — in every year the four digits spell, 0001..0099 included.
5152
if (fmtUtcDay(start) !== day) return null;
52-
return fmtUtcDay(new Date(Date.UTC(y, mo - 1, d + 1)));
53+
return fmtUtcDay(utcMidnight(y, mo - 1, d + 1));
5354
}
5455

5556
/**
@@ -103,6 +104,23 @@ export function utcInstantMs(value: unknown): number | null {
103104
return Number.isNaN(t) ? null : t;
104105
}
105106

107+
/**
108+
* Midnight UTC of a proleptic-Gregorian year, month index and day, with the
109+
* month and day rolled over past their ends as `Date.UTC` rolls them.
110+
*
111+
* Not `Date.UTC(year, …)` itself: it reads a year from 0 to 99 as 1900 + year,
112+
* so `0050-01-01` would be built as 1950-01-01, fail the round trip in
113+
* {@link nextUtcCalendarDay}, and every day of the years 0001..0099 would come
114+
* back `null` — a `$lte` or a `$between` maximum on such a day would then skip
115+
* whole-day widening and miss that day's rows. `setUTCFullYear` takes the
116+
* year as written.
117+
*/
118+
function utcMidnight(year: number, monthIndex: number, day: number): Date {
119+
const dt = new Date(0);
120+
dt.setUTCFullYear(year, monthIndex, day);
121+
return dt;
122+
}
123+
106124
/** `YYYY-MM-DD` of an instant's UTC calendar day. */
107125
function fmtUtcDay(dt: Date): string {
108126
return (

0 commit comments

Comments
 (0)