From fbd79d730c7f7af0aedc5777bc6fcd48147deb87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:48:52 +0000 Subject: [PATCH 1/4] fix(spec): nextUtcCalendarDay and utcInstantMs read years 0001..0099 as written, not as 1900..1999 Date.UTC reads a year from 0 to 99 as 1900 + year, so the round trip that proves a bare day real failed for every day of those years and both helpers answered null: a datetime $lte or $between maximum on such a day skipped whole-day widening. The date is now built with setUTCFullYear. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- packages/spec/src/data/calendar-day.test.ts | 50 ++++++++++++++++++++- packages/spec/src/data/calendar-day.ts | 26 +++++++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/data/calendar-day.test.ts b/packages/spec/src/data/calendar-day.test.ts index b36013cbf7e..aadb5f0422a 100644 --- a/packages/spec/src/data/calendar-day.test.ts +++ b/packages/spec/src/data/calendar-day.test.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it } from 'vitest'; -import { nextUtcCalendarDay } from './calendar-day'; +import { nextUtcCalendarDay, utcInstantMs } from './calendar-day'; describe('nextUtcCalendarDay', () => { it('advances one calendar day', () => { @@ -59,3 +59,51 @@ describe('nextUtcCalendarDay', () => { expect(nextUtcCalendarDay(next)).toBe('2027-01-02'); }); }); + +/** + * [#20550] Every day of the supported years 0001..9999 is a calendar day to + * both helpers, the years 0001..0099 included. `Date.UTC` reads a year from 0 + * to 99 as 1900 + year, so a construction through it built `0050-01-01` as + * 1950-01-01, the round trip failed, and both helpers answered `null` for + * every day of those years: a `$lte` or a `$between` maximum on such a day + * compiled to that day's midnight and missed the rest of it. Year 0100 is the + * control that always answered, and 2026 the everyday one. + */ +describe('[#20550] years 0001..0099 are calendar days, not 1900..1999', () => { + /** day · the day after it */ + const DAYS: ReadonlyArray = [ + ['0001-01-01', '0001-01-02'], + ['0050-01-01', '0050-01-02'], + ['0099-12-31', '0100-01-01'], // the century rolls over, not back to 1900 + ['0100-01-01', '0100-01-02'], // the control that answered before + ['2026-07-15', '2026-07-16'], // the everyday control + ]; + + it('nextUtcCalendarDay answers the day after, in the year as written', () => { + for (const [day, next] of DAYS) expect(nextUtcCalendarDay(day), day).toBe(next); + }); + + it("utcInstantMs answers that day's midnight UTC", () => { + for (const [day] of DAYS) { + const ms = utcInstantMs(day); + expect(ms, day).toBe(Date.parse(`${day}T00:00:00.000Z`)); + expect(new Date(ms!).toISOString(), day).toBe(`${day}T00:00:00.000Z`); + } + }); + + it('an impossible day in those years is still refused by both, not rolled over', () => { + for (const day of ['0050-02-30', '0001-13-01', '0099-04-31', '0100-02-29', '2026-02-30']) { + expect(nextUtcCalendarDay(day), day).toBeNull(); + expect(utcInstantMs(day), day).toBeNull(); + } + }); + + it('the leap rule is the proleptic Gregorian one in those years: 0004-02-29 is a day, 0100-02-29 is not', () => { + expect(nextUtcCalendarDay('0004-02-28')).toBe('0004-02-29'); + expect(nextUtcCalendarDay('0004-02-29')).toBe('0004-03-01'); + expect(utcInstantMs('0004-02-29')).toBe(Date.parse('0004-02-29T00:00:00.000Z')); + expect(nextUtcCalendarDay('0100-02-28')).toBe('0100-03-01'); // 0100 is not a leap year + expect(nextUtcCalendarDay('0100-02-29')).toBeNull(); + expect(utcInstantMs('0100-02-29')).toBeNull(); + }); +}); diff --git a/packages/spec/src/data/calendar-day.ts b/packages/spec/src/data/calendar-day.ts index 7776ed947b8..f03557d5bb7 100644 --- a/packages/spec/src/data/calendar-day.ts +++ b/packages/spec/src/data/calendar-day.ts @@ -45,11 +45,12 @@ export function nextUtcCalendarDay(value: unknown): string | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); if (!m) return null; const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; - const start = new Date(Date.UTC(y, mo - 1, d)); - // Reject a shape-valid but impossible day: `Date.UTC` rolls 2026-02-30 into - // March, so the round-trip is what proves the input was a real calendar day. + const start = utcMidnight(y, mo - 1, d); + // Reject a shape-valid but impossible day: the construction rolls 2026-02-30 + // into March, so the round-trip is what proves the input was a real calendar + // day — in every year the four digits spell, 0001..0099 included. if (fmtUtcDay(start) !== day) return null; - return fmtUtcDay(new Date(Date.UTC(y, mo - 1, d + 1))); + return fmtUtcDay(utcMidnight(y, mo - 1, d + 1)); } /** @@ -103,6 +104,23 @@ export function utcInstantMs(value: unknown): number | null { return Number.isNaN(t) ? null : t; } +/** + * Midnight UTC of a proleptic-Gregorian year, month index and day, with the + * month and day rolled over past their ends as `Date.UTC` rolls them. + * + * Not `Date.UTC(year, …)` itself: it reads a year from 0 to 99 as 1900 + year, + * so `0050-01-01` would be built as 1950-01-01, fail the round trip in + * {@link nextUtcCalendarDay}, and every day of the years 0001..0099 would come + * back `null` — a `$lte` or a `$between` maximum on such a day would then skip + * whole-day widening and miss that day's rows. `setUTCFullYear` takes the + * year as written. + */ +function utcMidnight(year: number, monthIndex: number, day: number): Date { + const dt = new Date(0); + dt.setUTCFullYear(year, monthIndex, day); + return dt; +} + /** `YYYY-MM-DD` of an instant's UTC calendar day. */ function fmtUtcDay(dt: Date): string { return ( From ea5478a5a2e348b72171cb0d587a433de0fee835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:58:02 +0000 Subject: [PATCH 2/4] test(rest): a datetime $lte and $between maximum include the whole day in year 0050, over REST on SQLite and PostgreSQL Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- ...-query-calendar-day-year-below-100.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/rest/src/data-query-calendar-day-year-below-100.test.ts diff --git a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts new file mode 100644 index 00000000000..dc6ebabd81e --- /dev/null +++ b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20550] A bare `YYYY-MM-DD` upper bound on a `datetime` field — `$lte`, or + * a `$between` maximum — includes the whole of that day in the years + * 0001..0099 exactly as it does in 2026, at the public door + * `POST /api/v1/data/:object/query`, over a real `SqlDriver`. + * + * The widening (ADR-0053 D-D) compiles `$lte day` to `< nextUtcCalendarDay(day)` + * midnight UTC, and falls back to `<= day` midnight when the helper answers + * `null`. `@objectstack/spec`'s `nextUtcCalendarDay` built the date through + * `Date.UTC`, which reads a year from 0 to 99 as 1900 + year, so the round trip + * that proves a day real failed for every day of those years and the bound + * stopped at the day's first instant. Measured through this door on the base, + * the process in America/New_York, on the six rows below: + * + * | `where opened_at` | SQLite | PostgreSQL 16 | the whole day | + * |:--|:--|:--|:--| + * | `$lte '0050-01-01'` | `y49` | `y49` | `y49`, `y50`, `y50_last` | + * | `$between ['0050-01-01', '0050-01-01']` | none | none | `y50`, `y50_last` | + * | `$lte '2026-07-15'` (control) | the whole day | the whole day | `y49` … `y50_next`, `c26` | + * | `$between ['2026-07-15', '2026-07-15']` (control) | `c26` | `c26` | `c26` | + * + * The next day's midnight (`y50_next`, `c26_next`) stays out in every cell: the + * bound is half-open, never an inclusive `23:59:59.999`. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI job + * provisions it for this package. It owns one table, dropped before and after. + * An in-memory cell is not here, for the reason + * `data-temporal-write-real-day-iso.test.ts` gives: `@objectstack/driver-memory` + * has no binding in this package. The helper's own pins, every year edge and + * the impossible days included, are `packages/spec/src/data/calendar-day.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_calendar_day_20550'; +const HOST_ZONE = 'America/New_York'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20550', + fields: { + memo: { name: 'memo', type: 'text' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + }, +}; + +/** id · the instant written through the door. */ +const ROWS: ReadonlyArray = [ + ['y49', '0049-12-31T10:00:00.000Z'], + ['y50', '0050-01-01T10:00:00.000Z'], // the card's row + ['y50_last', '0050-01-01T23:59:59.999Z'], // the day's last millisecond + ['y50_next', '0050-01-02T00:00:00.000Z'], // the next day's first instant: out + ['c26', '2026-07-15T14:00:00.000Z'], // the card's control row + ['c26_next', '2026-07-16T00:00:00.000Z'], +]; + +/** `where` on `opened_at` · the ids it answers, sorted. */ +const QUERIES: ReadonlyArray, readonly string[]]> = [ + ["$lte '0050-01-01'", { $lte: '0050-01-01' }, ['y49', 'y50', 'y50_last']], + ["$between max '0050-01-01'", { $between: ['0050-01-01', '0050-01-01'] }, ['y50', 'y50_last']], + ["$lte '2026-07-15' (control)", { $lte: '2026-07-15' }, ['c26', 'y49', 'y50', 'y50_last', 'y50_next']], + ["$between max '2026-07-15' (control)", { $between: ['2026-07-15', '2026-07-15'] }, ['c26']], +]; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const originalTz = process.env.TZ; + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#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)`}`, + () => { + let engine: ObjectQL; + let driver: any; + let call: (method: string, path: string, params: Record, body: unknown) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + // A host whose zone is not UTC, so a host-zone reading of a bound would show. + process.env.TZ = HOST_ZONE; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE); + + driver = new SqlDriver(config as any); + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + call = async (method, path, params, body) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + + for (const [id, openedAt] of ROWS) { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, memo: 'm', opened_at: openedAt }); + expect(created.status, `create ${id} ${openedAt}: ${JSON.stringify(created.body)}`).toBe(201); + } + }); + + afterAll(async () => { + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + }); + + const idsWhere = async (opened_at: Record) => { + const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { opened_at } }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + return (res.body.records as Array<{ id: string }>).map((r) => r.id).sort(); + }; + + it('every row reads back the instant it was written — the rows the bounds are measured against', async () => { + const res = await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { memo: 'm' } }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + const stored = Object.fromEntries((res.body.records as Array<{ id: string; opened_at: string }>).map((r) => [r.id, r.opened_at])); + expect(stored).toEqual(Object.fromEntries(ROWS)); + }); + + 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 () => { + for (const [name, bound, want] of QUERIES) { + expect(await idsWhere(bound), name).toEqual(want); + } + }); + }, + ); +} From 1d7cb9e862064912c1116e6e289e81ec744d370b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:59:57 +0000 Subject: [PATCH 3/4] test(rest): read all four bound cells before comparing, so a red run shows each Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../src/data-query-calendar-day-year-below-100.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts index dc6ebabd81e..1f77e20c9fc 100644 --- a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts +++ b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts @@ -166,9 +166,10 @@ for (const cell of CELLS) { }); 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 () => { - for (const [name, bound, want] of QUERIES) { - expect(await idsWhere(bound), name).toEqual(want); - } + // Every reading first, then one comparison, so a red run shows all four cells. + const got: Record = {}; + for (const [name, bound] of QUERIES) got[name] = await idsWhere(bound); + expect(got).toEqual(Object.fromEntries(QUERIES.map(([name, , want]) => [name, want]))); }); }, ); From 6a5dce7378eb31968ef97e55a579d87ff1b5cdf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:03:21 +0000 Subject: [PATCH 4/4] chore(changeset): spec patch for bare days in years 0001..0099 Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20550-calendar-day-years-below-100.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/20550-calendar-day-years-below-100.md diff --git a/.changeset/20550-calendar-day-years-below-100.md b/.changeset/20550-calendar-day-years-below-100.md new file mode 100644 index 00000000000..8d08bfa2f26 --- /dev/null +++ b/.changeset/20550-calendar-day-years-below-100.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `nextUtcCalendarDay` and `utcInstantMs` read a bare day in the years 0001..0099 as written, not as 1900..1999 + +`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. + +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.