From 62213ff13fac03c7dd58744dd4e84c82885403bf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 00:41:58 +0000 Subject: [PATCH 1/3] fix(core): build a UTC instant from parts without Date.UTC's two-digit-year remap wallClockToUtcMs replaces Date.UTC at zonedWallClockToUtcMs (and its offset read), the ISO-week label, bucketKeyToCalendarRange, filter-tokens, service-analytics' week keys and trigger-schedule's day window. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- packages/core/src/utils/datetime.ts | 88 ++++++++++++++----- packages/core/src/utils/filter-tokens.ts | 45 ++++++---- .../service-analytics/src/dataset-executor.ts | 5 +- .../src/preview-evaluator.ts | 5 +- .../src/time-relative-trigger.ts | 14 ++- 5 files changed, 113 insertions(+), 44 deletions(-) diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index dc023b37876..fda5c68ba9b 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -38,6 +38,31 @@ export interface WallClockParts extends CalendarParts { millisecond?: number; } +/** + * [#20599] The epoch milliseconds of a wall clock read as **UTC**: `Date.UTC` + * without its two-digit-year remap. The zone-free sibling of + * {@link zonedWallClockToUtcMs}, and the one construction this package and its + * callers use to build a UTC instant from year / month / day / time parts. + * + * Not `Date.UTC(year, …)` and not `new Date(year, …)`: both read a year from 0 + * to 99 as 1900 + year (ECMA-262 `MakeFullYear`), so `0050-01-01` is built as + * 1950-01-01 — while 0001..9999 is the supported range (`temporalStorageForm`). + * `setUTCFullYear` takes the year as written. + * + * Every component rolls over past its end exactly as `Date.UTC` rolls it, and + * callers rely on that: `month: 13` is January of the next year, `month: 0` is + * December of the previous one, `day: 0` is the previous month's last day, + * `day: 32` runs into the next month, and `hour: 24` is the next day's + * midnight. Omitted time components default to 0. A `NaN` or non-finite + * component, or an instant outside the `Date` range, gives `NaN`, as + * `Date.UTC` does. + */ +export function wallClockToUtcMs(parts: WallClockParts): number { + const dt = new Date(0); + dt.setUTCFullYear(parts.year, parts.month - 1, parts.day); + return dt.setUTCHours(parts.hour ?? 0, parts.minute ?? 0, parts.second ?? 0, parts.millisecond ?? 0); +} + /** * The year/month/day an instant falls on in `tz`. Throws if `tz` is not a * valid IANA zone (callers treat that as a fall-through to UTC). @@ -138,17 +163,16 @@ export function zonedDateStartToUtcMs(ymd: string, tz?: string): number { * and a host `TZ` fallback would reintroduce exactly the deployment-dependent * instant this primitive exists to remove. A parts object that produces an * invalid date (`NaN` components) returns `NaN`, as `Date.UTC` does. + * + * [#20599] A year from 0001 to 0099 is read as written, never as 1900 + year: + * both the wall clock and the offset read below go through + * {@link wallClockToUtcMs}, not `Date.UTC`. The offset read takes the zone's + * year with its era, because `Intl`'s `year` part is an ERA year — 1 BC reads + * `1` — and the first probe for a wall clock early on 0001-01-01 in a zone + * west of UTC lands in year 0. */ export function zonedWallClockToUtcMs(parts: WallClockParts, tz?: string): number { - const wallAsUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour ?? 0, - parts.minute ?? 0, - parts.second ?? 0, - parts.millisecond ?? 0, - ); + const wallAsUtc = wallClockToUtcMs(parts); if (!tz || tz === 'UTC' || Number.isNaN(wallAsUtc)) return wallAsUtc; try { // The tz offset (local − UTC, in ms) at instant `t`: read t's wall clock in @@ -162,6 +186,7 @@ export function zonedWallClockToUtcMs(parts: WallClockParts, tz?: string): numbe const p = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', + era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', @@ -170,8 +195,17 @@ export function zonedWallClockToUtcMs(parts: WallClockParts, tz?: string): numbe second: '2-digit', }).formatToParts(new Date(whole)); const g = (k: string) => Number(p.find((x) => x.type === k)?.value); + const eraYear = g('year'); + const year = p.find((x) => x.type === 'era')?.value === 'BC' ? 1 - eraYear : eraYear; return ( - Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - whole + wallClockToUtcMs({ + year, + month: g('month'), + day: g('day'), + hour: g('hour'), + minute: g('minute'), + second: g('second'), + }) - whole ); }; // Want U such that localParts(U) == the wall clock, i.e. U = wallAsUtc − offset(U). @@ -309,12 +343,15 @@ export function bucketDateKey( * The ONE statement of the week rule in this package: {@link bucketDateKey}'s * `week` branch and {@link isoWeekLabelUtc} both call it, so the forward label * and the round-trip validator that checks it cannot drift apart. + * + * [#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. */ function isoWeekLabelFromCalendarDay(year: number, month: number, day: number): string { - const target = new Date(Date.UTC(year, month - 1, day)); + const target = new Date(wallClockToUtcMs({ year, month, day })); const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6 target.setUTCDate(target.getUTCDate() - dayNum + 3); // shift to that week's Thursday - const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); + const firstThursday = new Date(wallClockToUtcMs({ year: target.getUTCFullYear(), month: 1, day: 4 })); const weekNo = 1 + Math.round( @@ -354,6 +391,12 @@ function isoWeekLabelUtc(d: Date): string { * `key` admits `null` because that IS the empty bucket's key on both aggregation * paths (#3839); callers pass a grouped row's dimension value straight through * rather than casting a lie. + * + * [#20599] Every bound is built by {@link wallClockToUtcMs}, so a key in + * 0001..0099 (`0050`, `0050-Q1`, `0050-01`, `0050-01-01`) spans its own year, + * 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. */ export function bucketKeyToCalendarRange( key: string | null | undefined, @@ -365,22 +408,25 @@ export function bucketKeyToCalendarRange( 2, '0', )}-${String(dt.getUTCDate()).padStart(2, '0')}`; + /** 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 })); switch (granularity) { case 'year': { const m = /^(\d{4})$/.exec(key); if (!m) return null; const y = Number(m[1]); - return { start: fmt(new Date(Date.UTC(y, 0, 1))), end: fmt(new Date(Date.UTC(y + 1, 0, 1))) }; + return { start: fmt(utcDay(y, 1, 1)), end: fmt(utcDay(y + 1, 1, 1)) }; } case 'quarter': { const m = /^(\d{4})-Q([1-4])$/.exec(key); if (!m) return null; const y = Number(m[1]); - const startMonth = (Number(m[2]) - 1) * 3; // Q1→0, Q2→3, Q3→6, Q4→9 + const startMonth = (Number(m[2]) - 1) * 3 + 1; // Q1→1, Q2→4, Q3→7, Q4→10 return { - start: fmt(new Date(Date.UTC(y, startMonth, 1))), - end: fmt(new Date(Date.UTC(y, startMonth + 3, 1))), // Date.UTC rolls Q4 into next year + start: fmt(utcDay(y, startMonth, 1)), + end: fmt(utcDay(y, startMonth + 3, 1)), // month 13 rolls Q4 into next year }; } case 'month': { @@ -390,8 +436,8 @@ export function bucketKeyToCalendarRange( if (mo < 1 || mo > 12) return null; const y = Number(m[1]); return { - start: fmt(new Date(Date.UTC(y, mo - 1, 1))), - end: fmt(new Date(Date.UTC(y, mo, 1))), + start: fmt(utcDay(y, mo, 1)), + end: fmt(utcDay(y, mo + 1, 1)), }; } case 'day': { @@ -400,9 +446,9 @@ export function bucketKeyToCalendarRange( const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); - const start = new Date(Date.UTC(y, mo - 1, d)); + const start = utcDay(y, mo, d); if (fmt(start) !== key) return null; // reject an impossible day that rolled over - return { start: key, end: fmt(new Date(Date.UTC(y, mo - 1, d + 1))) }; + return { start: key, end: fmt(utcDay(y, mo, d + 1)) }; } case 'week': { const m = /^(\d{4})-W(\d{2})$/.exec(key); @@ -411,7 +457,7 @@ export function bucketKeyToCalendarRange( const week = Number(m[2]); if (week < 1 || week > 53) return null; // Monday of ISO week 1 is the Monday on/before Jan 4; add (week-1) weeks. - const jan4 = new Date(Date.UTC(isoYear, 0, 4)); + const jan4 = utcDay(isoYear, 1, 4); const jan4Dow = (jan4.getUTCDay() + 6) % 7; // Mon=0..Sun=6 const start = new Date(jan4.getTime()); start.setUTCDate(jan4.getUTCDate() - jan4Dow + (week - 1) * 7); diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index e88212c3df9..f000fa5d1b2 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -91,7 +91,8 @@ import { parseDateMacroParam, type DateMacroUnit, } from '@objectstack/spec/data'; -import { calendarPartsInTzOrUtc } from './datetime.js'; +import { calendarPartsInTzOrUtc, wallClockToUtcMs } from './datetime.js'; +import { temporalStorageForm } from './temporal-storage-form.js'; /** * The slice of an execution context the resolver reads. Structural on purpose — @@ -180,25 +181,29 @@ export class UnresolvedFilterTokenError extends Error { } } -/** `YYYY-MM-DD` for a calendar day, zero-padded. */ -function ymd(year: number, month: number, day: number): string { - const p = (n: number) => String(n).padStart(2, '0'); - return `${year}-${p(month)}-${p(day)}`; -} - /** * Calendar arithmetic is done on a UTC "proxy" date built from the reference * timezone's calendar parts. Working in UTC keeps the math free of DST jumps * (a local-midnight `Date` can shift by an hour when `setMonth` crosses a * transition); the zone only decides WHICH calendar day "now" is, which * {@link calendarPartsInTzOrUtc} answers from the platform tz database. + * + * [#20599] Every proxy date in this file is built by `wallClockToUtcMs`, never + * `Date.UTC`, which reads a year from 0 to 99 as 1900 + year: a step that lands + * in 0001..0099 (`{1977_years_ago}`) stays there. */ function proxyDay(now: Date, timezone?: string): Date { - const { year, month, day } = calendarPartsInTzOrUtc(now, timezone); - return new Date(Date.UTC(year, month - 1, day)); + return new Date(wallClockToUtcMs(calendarPartsInTzOrUtc(now, timezone))); } -const asYmd = (d: Date): string => ymd(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()); +/** + * `YYYY-MM-DD` of a proxy date: the storage rule's `date` spelling, whose year + * is padded to four digits (`0049-09-30`, never `49-09-30`, which names no + * day). [#20599] Before the proxy dates kept their year, a step into + * 0001..0099 came out in the 1900s instead, so this spelling was never reached + * for those years; a step into 0100..0999 was already spelled unpadded. + */ +const asYmd = (d: Date): string => String(temporalStorageForm(d, 'date')); type PeriodKind = 'week' | 'month' | 'quarter' | 'year'; @@ -212,17 +217,19 @@ function startOfPeriod(kind: PeriodKind, d: Date): Date { return r; } case 'month': - return new Date(Date.UTC(r.getUTCFullYear(), r.getUTCMonth(), 1)); + return new Date(wallClockToUtcMs({ year: r.getUTCFullYear(), month: r.getUTCMonth() + 1, day: 1 })); case 'quarter': - return new Date(Date.UTC(r.getUTCFullYear(), Math.floor(r.getUTCMonth() / 3) * 3, 1)); + return new Date( + wallClockToUtcMs({ year: r.getUTCFullYear(), month: Math.floor(r.getUTCMonth() / 3) * 3 + 1, day: 1 }), + ); case 'year': - return new Date(Date.UTC(r.getUTCFullYear(), 0, 1)); + return new Date(wallClockToUtcMs({ year: r.getUTCFullYear(), month: 1, day: 1 })); } } -/** Days in the given (0-based) month of `year`. */ +/** Days in the given (0-based) month of `year`: day 0 of the next month rolls back to its last. */ function daysInMonth(year: number, month: number): number { - return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + return new Date(wallClockToUtcMs({ year, month: month + 2, day: 0 })).getUTCDate(); } /** @@ -240,10 +247,10 @@ function addMonthsClamped(d: Date, n: number): Date { const targetYear = year + Math.floor(month / 12); const targetMonth = ((month % 12) + 12) % 12; const day = Math.min(d.getUTCDate(), daysInMonth(targetYear, targetMonth)); - return new Date(Date.UTC( - targetYear, targetMonth, day, - d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds(), - )); + return new Date(wallClockToUtcMs({ + year: targetYear, month: targetMonth + 1, day, + hour: d.getUTCHours(), minute: d.getUTCMinutes(), second: d.getUTCSeconds(), millisecond: d.getUTCMilliseconds(), + })); } /** Shift `d` by `n` whole periods of `kind` (negative shifts backwards). */ diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 68c9cb3dfde..cd459e5881e 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -16,6 +16,7 @@ import { filterTokenContextFrom, resolveAnalyticsDateRangeString, resolveFilterTokens, + wallClockToUtcMs, zonedDateStartToUtcMs, type LoweredDateRangeWindow, } from '@objectstack/core'; @@ -838,7 +839,9 @@ 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 - const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); + // [#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( diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index b5949b8e98c..a5b80772b1f 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -36,6 +36,7 @@ import { resolveAnalyticsDateRangeString, utcInstantMs, isUnboundedAbove, + wallClockToUtcMs, } from '@objectstack/core'; import { explicitDateRangeWindow } from './date-range-array-arm.js'; // [#19810] The `where` door's refusal envelope — `INVALID_FILTER` / 400, @@ -364,7 +365,9 @@ export function bucketDate(value: unknown, granularity: string, timezone?: strin case 'month': return `${y}-${m}`; case 'week': { // Build a UTC date from the zone-shifted parts, then step back to Monday. - const monday = new Date(Date.UTC(y, month - 1, dayNum)); + // [#20599] Through core's `wallClockToUtcMs`, never `Date.UTC`, which + // reads a year from 0 to 99 as 1900 + year. + const monday = new Date(wallClockToUtcMs({ year: y, month, day: dayNum })); const dow = (monday.getUTCDay() + 6) % 7; // Monday=0 monday.setUTCDate(monday.getUTCDate() - dow); return monday.toISOString().slice(0, 10); diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index d0e703fed08..714341b2f17 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -19,6 +19,7 @@ import { type ScheduledWorkTriggerOptions, } from './schedule-trigger.js'; import type { ScheduledRunOwnership } from '@objectstack/types'; +import { wallClockToUtcMs } from '@objectstack/core'; // [#18378] The ONE resolver for "which organization does this record BELONG // to" — the WALL question (`tenancy.enabled: false` ⇒ nothing, then a declared // `tenancy.tenantField`, then the kernel's `organization_id`), shared with the @@ -113,14 +114,23 @@ export interface DateWindow { // ─── Pure window math (day-granular, UTC) ─────────────────────────── +/** + * `d`'s UTC calendar day as parts. [#20599] Both bounds below are built from + * them by core's `wallClockToUtcMs`, never `Date.UTC`, which reads a year from + * 0 to 99 as 1900 + year: an offset that reaches 0001..0099 keeps its year. + */ +function utcDayParts(d: Date): { year: number; month: number; day: number } { + return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }; +} + /** Start of `d`'s UTC calendar day (00:00:00.000Z). */ function startOfUtcDay(d: Date): Date { - return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); + return new Date(wallClockToUtcMs(utcDayParts(d))); } /** End of `d`'s UTC calendar day (23:59:59.999Z) — inclusive upper bound. */ function endOfUtcDay(d: Date): Date { - return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23, 59, 59, 999)); + return new Date(wallClockToUtcMs({ ...utcDayParts(d), hour: 23, minute: 59, second: 59, millisecond: 999 })); } /** `d`'s UTC day shifted by `n` whole days (exact in UTC — no DST drift). */ From 3d2301ee421bd7123e327db8c9551f87b610c7c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 00:47:42 +0000 Subject: [PATCH 2/3] test: pin years 0001..0099 at every site that built a UTC instant with Date.UTC Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../src/utils/datetime-year-below-100.test.ts | 252 ++++++++++++++++++ .../filter-tokens-year-below-100.test.ts | 79 ++++++ .../import-datetime-year-below-100.test.ts | 249 +++++++++++++++++ .../__tests__/week-key-year-below-100.test.ts | 95 +++++++ ...ime-relative-window-year-below-100.test.ts | 58 ++++ 5 files changed, 733 insertions(+) create mode 100644 packages/core/src/utils/datetime-year-below-100.test.ts create mode 100644 packages/core/src/utils/filter-tokens-year-below-100.test.ts create mode 100644 packages/rest/src/import-datetime-year-below-100.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts create mode 100644 packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts diff --git a/packages/core/src/utils/datetime-year-below-100.test.ts b/packages/core/src/utils/datetime-year-below-100.test.ts new file mode 100644 index 00000000000..7c75bcde8ac --- /dev/null +++ b/packages/core/src/utils/datetime-year-below-100.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20599] Wherever core builds a UTC instant from year / month / day / time +// parts, a year from 0001 to 0099 is read as itself. `Date.UTC(year, …)` and +// `new Date(year, …)` read a year from 0 to 99 as 1900 + year (ECMA-262 +// `MakeFullYear`); `wallClockToUtcMs` does not, and every site in this file +// builds through it. +// +// Pins: 0001, 0050 and 0099 (the remapped years), 0100 (the first year +// `Date.UTC` reads as written) and a 2026 control, in UTC and in Asia/Shanghai +// wherever the site takes a zone. Every expected instant is spelled as an ISO +// string, never computed by the code under test. Before 1901 the tz database +// gives Asia/Shanghai its local mean time, +08:05:43, and America/New_York +// −04:56:02: that is the offset a wall clock in those years is read at. + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { + wallClockToUtcMs, + zonedWallClockToUtcMs, + zonedDateStartToUtcMs, + bucketDateKey, + bucketKeyToCalendarRange, +} from './datetime.js'; + +const isoOf = (ms: number) => new Date(ms).toISOString(); + +const YEARS = ['0001', '0050', '0099', '0100', '2026'] as const; + +// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read +// UTC components only, and the answer must not move with the process zone. +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; +const originalTz = process.env.TZ; +afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(HOSTS)('on a %s host', (host) => { + beforeEach(() => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + }); + + describe('[#20599] wallClockToUtcMs builds a UTC instant with the year as written', () => { + it.each(YEARS)('%s-01-01 is midnight UTC of that year', (y) => { + expect(isoOf(wallClockToUtcMs({ year: Number(y), month: 1, day: 1 }))).toBe(`${y}-01-01T00:00:00.000Z`); + }); + + it.each(YEARS)('%s-06-15 10:20:30.456 keeps every time component', (y) => { + const ms = wallClockToUtcMs({ year: Number(y), month: 6, day: 15, hour: 10, minute: 20, second: 30, millisecond: 456 }); + expect(isoOf(ms)).toBe(`${y}-06-15T10:20:30.456Z`); + }); + + it('answers exactly what Date.UTC answers from year 100 on', () => { + for (const [year, month, day, hour, minute, second, ms] of [ + [100, 1, 1, 0, 0, 0, 0], + [999, 12, 31, 23, 59, 59, 999], + [2026, 7, 15, 10, 0, 0, 0], + [9999, 12, 31, 23, 59, 59, 999], + ]) { + expect(wallClockToUtcMs({ year, month, day, hour, minute, second, millisecond: ms })).toBe( + Date.UTC(year, month - 1, day, hour, minute, second, ms), + ); + } + }); + + // H5: the rollover is load-bearing — `bucketKeyToCalendarRange` ends Q4 and + // December at month 13, and `filter-tokens` counts a month's days as day 0 of + // the next. The helper rolls every component over as `Date.UTC` does. + describe('rolls every component over past its end, as Date.UTC does', () => { + it.each([ + ['month 13 is January of the next year', { year: 50, month: 13, day: 1 }, '0051-01-01T00:00:00.000Z'], + ['month 0 is December of the previous year', { year: 50, month: 0, day: 1 }, '0049-12-01T00:00:00.000Z'], + ['day 0 is the last day of the previous month', { year: 50, month: 3, day: 0 }, '0050-02-28T00:00:00.000Z'], + ['day 0 of March is February 29 in a leap year', { year: 48, month: 3, day: 0 }, '0048-02-29T00:00:00.000Z'], + ['day 32 of December runs into the next year', { year: 50, month: 12, day: 32 }, '0051-01-01T00:00:00.000Z'], + ['February 29 of a common year is March 1', { year: 50, month: 2, day: 29 }, '0050-03-01T00:00:00.000Z'], + ['hour 24 is the next midnight', { year: 99, month: 12, day: 31, hour: 24 }, '0100-01-01T00:00:00.000Z'], + ['hour −1 is the previous evening', { year: 50, month: 1, day: 1, hour: -1 }, '0049-12-31T23:00:00.000Z'], + ['day 0 of January of year 1 is the last day of year 0', { year: 1, month: 1, day: 0 }, '0000-12-31T00:00:00.000Z'], + ])('%s', (_label, parts, expected) => { + expect(isoOf(wallClockToUtcMs(parts))).toBe(expected); + }); + + it('rolls the 2026 control exactly as Date.UTC does', () => { + expect(wallClockToUtcMs({ year: 2026, month: 13, day: 1 })).toBe(Date.UTC(2026, 12, 1)); + expect(wallClockToUtcMs({ year: 2026, month: 3, day: 0 })).toBe(Date.UTC(2026, 2, 0)); + expect(wallClockToUtcMs({ year: 2026, month: 12, day: 32 })).toBe(Date.UTC(2026, 11, 32)); + expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: 24 })).toBe(Date.UTC(2026, 0, 1, 24)); + }); + }); + + it('answers NaN for a NaN or non-finite component, as Date.UTC does', () => { + expect(wallClockToUtcMs({ year: Number.NaN, month: 1, day: 1 })).toBeNaN(); + expect(wallClockToUtcMs({ year: 2026, month: Number.NaN, day: 1 })).toBeNaN(); + expect(wallClockToUtcMs({ year: 2026, month: 1, day: Number.NaN })).toBeNaN(); + expect(wallClockToUtcMs({ year: 2026, month: 1, day: 1, hour: Number.NaN })).toBeNaN(); + expect(wallClockToUtcMs({ year: Number.POSITIVE_INFINITY, month: 1, day: 1 })).toBeNaN(); + }); + }); + + describe('[#20599] zonedWallClockToUtcMs reads a year below 100 as written', () => { + it.each(YEARS)('%s-01-01 10:00 with no zone, UTC or an unknown zone is 10:00 UTC that day', (y) => { + for (const tz of [undefined, 'UTC', 'Not/AZone']) { + expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, tz))).toBe( + `${y}-01-01T10:00:00.000Z`, + ); + } + }); + + it.each([ + ['0001', '0001-01-01T01:54:17.000Z'], + ['0050', '0050-01-01T01:54:17.000Z'], + ['0099', '0099-01-01T01:54:17.000Z'], + ['0100', '0100-01-01T01:54:17.000Z'], + ['2026', '2026-01-01T02:00:00.000Z'], + ])('%s-01-01 10:00 in Asia/Shanghai is read at that year\'s offset', (y, expected) => { + expect(isoOf(zonedWallClockToUtcMs({ year: Number(y), month: 1, day: 1, hour: 10 }, 'Asia/Shanghai'))).toBe(expected); + }); + + it('keeps the last second of 0099 in 0099 in Asia/Shanghai', () => { + const ms = zonedWallClockToUtcMs({ year: 99, month: 12, day: 31, hour: 23, minute: 59, second: 59 }, 'Asia/Shanghai'); + expect(isoOf(ms)).toBe('0099-12-31T15:54:16.000Z'); + }); + + // The offset read probes the zone at the wall clock taken as UTC. Early on + // 0001-01-01 in a zone west of UTC that probe is still in year 0, whose + // `Intl` year part is the era year `1` (1 BC): read without its era, the + // probe would be a year off and so would the answer. + it('reads 0001-01-01 in America/New_York, where the offset probe lands in year 0', () => { + expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1 }, 'America/New_York'))).toBe( + '0001-01-01T04:56:02.000Z', + ); + expect(isoOf(zonedWallClockToUtcMs({ year: 1, month: 1, day: 1, hour: 3 }, 'America/New_York'))).toBe( + '0001-01-01T07:56:02.000Z', + ); + expect(isoOf(zonedWallClockToUtcMs({ year: 2026, month: 1, day: 1 }, 'America/New_York'))).toBe( + '2026-01-01T05:00:00.000Z', + ); + }); + }); + + describe('[#20599] zonedDateStartToUtcMs', () => { + it.each(YEARS)('%s-01-01 begins at midnight UTC with no zone and in UTC', (y) => { + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`))).toBe(`${y}-01-01T00:00:00.000Z`); + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'UTC'))).toBe(`${y}-01-01T00:00:00.000Z`); + }); + + it.each([ + ['0001', '0000-12-31T15:54:17.000Z'], + ['0050', '0049-12-31T15:54:17.000Z'], + ['0099', '0098-12-31T15:54:17.000Z'], + ['0100', '0099-12-31T15:54:17.000Z'], + ['2026', '2025-12-31T16:00:00.000Z'], + ])('%s-01-01 begins at Asia/Shanghai midnight', (y, expected) => { + expect(isoOf(zonedDateStartToUtcMs(`${y}-01-01`, 'Asia/Shanghai'))).toBe(expected); + }); + }); + + 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. + const weekOf = (key: string | null) => { + const m = /^(\d+)-W(\d{2})$/.exec(String(key)); + return m ? { year: Number(m[1]), week: Number(m[2]) } : key; + }; + + it.each([ + ['0001-01-01T10:00:00.000Z', { year: 1, week: 1 }], + ['0050-01-01T10:00:00.000Z', { year: 49, week: 52 }], + ['0050-06-15T10:00:00.000Z', { year: 50, week: 24 }], + ['0099-12-31T10:00:00.000Z', { year: 99, week: 53 }], + ['0100-01-04T10:00:00.000Z', { year: 100, week: 1 }], + ['2026-06-15T10:00:00.000Z', { year: 2026, week: 25 }], + ])('%s in UTC', (instant, expected) => { + expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(expected); + expect(weekOf(bucketDateKey(instant, 'week', 'UTC'))).toEqual(expected); + }); + + it.each([ + // Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai: week 1 of 0050. + ['0050-01-02T20:00:00.000Z', { year: 49, week: 52 }, { year: 50, week: 1 }], + ['0001-01-07T20:00:00.000Z', { year: 1, week: 1 }, { year: 1, week: 2 }], + ['0099-12-31T20:00:00.000Z', { year: 99, week: 53 }, { year: 99, week: 53 }], + ['2026-06-14T20:00:00.000Z', { year: 2026, week: 24 }, { year: 2026, week: 25 }], + ])('%s in UTC and in Asia/Shanghai', (instant, utc, shanghai) => { + expect(weekOf(bucketDateKey(instant, 'week'))).toEqual(utc); + expect(weekOf(bucketDateKey(instant, 'week', 'Asia/Shanghai'))).toEqual(shanghai); + }); + }); + + describe('[#20599] bucketKeyToCalendarRange spans a key in 0001..0099 in its own year', () => { + it.each([ + ['0001', '0001-01-01', '0002-01-01'], + ['0050', '0050-01-01', '0051-01-01'], + ['0099', '0099-01-01', '0100-01-01'], + ['0100', '0100-01-01', '0101-01-01'], + ['2026', '2026-01-01', '2027-01-01'], + ])('year %s', (key, start, end) => { + expect(bucketKeyToCalendarRange(key, 'year')).toEqual({ start, end }); + }); + + it.each([ + ['0001-Q2', '0001-04-01', '0001-07-01'], + ['0050-Q1', '0050-01-01', '0050-04-01'], + ['0050-Q4', '0050-10-01', '0051-01-01'], + ['0099-Q4', '0099-10-01', '0100-01-01'], + ['0100-Q4', '0100-10-01', '0101-01-01'], + ['2026-Q4', '2026-10-01', '2027-01-01'], + ])('quarter %s', (key, start, end) => { + expect(bucketKeyToCalendarRange(key, 'quarter')).toEqual({ start, end }); + }); + + it.each([ + ['0001-02', '0001-02-01', '0001-03-01'], + ['0050-01', '0050-01-01', '0050-02-01'], + ['0050-12', '0050-12-01', '0051-01-01'], + ['0099-12', '0099-12-01', '0100-01-01'], + ['0100-12', '0100-12-01', '0101-01-01'], + ['2026-12', '2026-12-01', '2027-01-01'], + ])('month %s', (key, start, end) => { + expect(bucketKeyToCalendarRange(key, 'month')).toEqual({ start, end }); + }); + + it.each([ + ['0001-01-01', '0001-01-02'], + ['0048-02-29', '0048-03-01'], + ['0050-01-01', '0050-01-02'], + ['0050-12-31', '0051-01-01'], + ['0099-12-31', '0100-01-01'], + ['0100-01-01', '0100-01-02'], + ['2026-02-28', '2026-03-01'], + ])('day %s', (key, end) => { + expect(bucketKeyToCalendarRange(key, 'day')).toEqual({ start: key, end }); + }); + + it('still refuses an impossible day in 0001..0099 rather than rolling it over', () => { + expect(bucketKeyToCalendarRange('0050-02-29', 'day')).toBeNull(); // 0050 is not a leap year + expect(bucketKeyToCalendarRange('0099-04-31', 'day')).toBeNull(); + expect(bucketKeyToCalendarRange('0100-02-29', 'day')).toBeNull(); // 0100 is not a leap year + 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. + it('week 2026-W01 (the control)', () => { + expect(bucketKeyToCalendarRange('2026-W01', 'week')).toEqual({ start: '2025-12-29', end: '2026-01-05' }); + }); + }); +}); diff --git a/packages/core/src/utils/filter-tokens-year-below-100.test.ts b/packages/core/src/utils/filter-tokens-year-below-100.test.ts new file mode 100644 index 00000000000..4786993a8ec --- /dev/null +++ b/packages/core/src/utils/filter-tokens-year-below-100.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20599] A date macro that steps into 0001..0099 resolves to a day in that +// year, spelled `YYYY-MM-DD`. The resolver's proxy dates were built by +// `Date.UTC(year, …)`, which reads a year from 0 to 99 as 1900 + year, so +// `{1976_years_ago}` resolved to a day in 1950. They are built by core's +// `wallClockToUtcMs` now, and the day is spelled by the storage rule's `date` +// form, whose year is padded to four digits. +// +// Pins: 0001, 0050 and 0099, with 0100 (the first year `Date.UTC` reads as +// written) and a 2026 control, in UTC and in Asia/Shanghai. + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { resolveFilterToken } from './filter-tokens.js'; + +// Wed 2026-09-30 12:00 UTC: the same calendar day in UTC and in Asia/Shanghai. +const NOW = new Date('2026-09-30T12:00:00.000Z'); +// Wed 2026-09-30 20:00 UTC: Thu 2026-10-01 04:00 in Asia/Shanghai. +const LATE = new Date('2026-09-30T20:00:00.000Z'); + +const at = (token: string, now: Date, timezone?: string) => resolveFilterToken(token, { now, timezone }); + +// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read +// UTC components only, and the answer must not move with the process zone. +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; +const originalTz = process.env.TZ; +afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(HOSTS)('on a %s host', (host) => { + beforeEach(() => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + }); + + describe('[#20599] a year step into 0001..0099 keeps its year', () => { + it.each([ + ['2025_years_ago', '0001-09-30'], + ['1976_years_ago', '0050-09-30'], + ['1927_years_ago', '0099-09-30'], + ['1926_years_ago', '0100-09-30'], + ['1_year_ago', '2025-09-30'], + ['24000_months_ago', '0026-09-30'], + ])('{%s} in UTC is %s', (token, expected) => { + expect(at(token, NOW)).toBe(expected); + expect(at(token, NOW, 'UTC')).toBe(expected); + expect(at(token, NOW, 'Asia/Shanghai')).toBe(expected); + }); + + it.each([ + ['2025_years_ago', '0001-09-30', '0001-10-01'], + ['1976_years_ago', '0050-09-30', '0050-10-01'], + ['1927_years_ago', '0099-09-30', '0099-10-01'], + ['1926_years_ago', '0100-09-30', '0100-10-01'], + ['1_year_ago', '2025-09-30', '2025-10-01'], + ])('{%s} steps from the reference zone\'s day: %s in UTC, %s in Asia/Shanghai', (token, utc, shanghai) => { + expect(at(token, LATE)).toBe(utc); + expect(at(token, LATE, 'Asia/Shanghai')).toBe(shanghai); + }); + + it('clamps February 29 to the length of February in the year it lands in', () => { + const leapDay = new Date('2028-02-29T12:00:00.000Z'); + expect(at('1978_years_ago', leapDay)).toBe('0050-02-28'); // 0050 is a common year + expect(at('1980_years_ago', leapDay)).toBe('0048-02-29'); // 0048 is a leap year + expect(at('1928_years_ago', leapDay)).toBe('0100-02-28'); // 0100 is a common year + expect(at('4_years_ago', leapDay)).toBe('2024-02-29'); + }); + + // A day step never went through `Date.UTC`; its year below 1000 was spelled + // unpadded (`55-06-15`), which names no `YYYY-MM-DD` day. + it('spells a day step into 0001..0999 with a four-digit year', () => { + expect(at('720000_days_ago', NOW)).toBe('0055-06-15'); + expect(at('739000_days_ago', NOW)).toBe('0003-06-08'); + expect(at('1_day_ago', NOW)).toBe('2026-09-29'); + }); + }); +}); diff --git a/packages/rest/src/import-datetime-year-below-100.test.ts b/packages/rest/src/import-datetime-year-below-100.test.ts new file mode 100644 index 00000000000..468e0fff3d5 --- /dev/null +++ b/packages/rest/src/import-datetime-year-below-100.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20599] `POST /api/v1/data/:object/import` stores a zone-naive `datetime` + * cell in 0001..0099 in its own year. + * + * The cell is a wall clock, read in the business timezone by core's + * `zonedWallClockToUtcMs`. That built the wall clock, and read the zone's + * offset, with `Date.UTC(year, …)`, which reads a year from 0 to 99 as + * 1900 + year: the cell `0050-01-01 10:00` was stored as + * `1950-01-01T10:00:00.000Z`, `ok` and with no error. Core builds through + * `wallClockToUtcMs` now; this reader is unchanged. + * + * Three layers, each with 0001, 0050 and 0099, 0100 (the first year `Date.UTC` + * reads as written) and a 2026 control: + * + * - **the cell reader** (`parseDateCell`), with no zone, in UTC and in + * Asia/Shanghai, on a UTC host and an Asia/Shanghai host; + * - **the import door**: a CSV imported over a real engine (SqlDriver on + * SQLite) stores the instant the cell names; + * - **export → import**: rows written through the create door, exported as CSV + * and JSON and re-imported into a fresh stack, store the same instant, under + * no business timezone, Asia/Shanghai and America/New_York. + * + * The export writes a year below 1000 unpadded (`50-01-01 10:00:00`), which the + * import refuses: that is #20602's half of the round trip, not this card's. So + * the round trip below pads the exported year to four digits, as #20602's + * export will write it, before re-importing; once the export pads, that step + * changes nothing. Before 1901 the tz database gives Asia/Shanghai its local + * mean time, +08:05:43, and America/New_York −04:56:02, which is the offset + * both directions read those years at. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; +import { parseDateCell } from './import-coerce.js'; + +const SHANGHAI = 'Asia/Shanghai'; +const NEW_YORK = 'America/New_York'; + +/** Each cell, and the instant it names with no zone and in Asia/Shanghai. */ +const CELLS = [ + { id: 'c0001', cell: '0001-01-01 10:00', utc: '0001-01-01T10:00:00.000Z', shanghai: '0001-01-01T01:54:17.000Z' }, + { id: 'c0050', cell: '0050-01-01 10:00', utc: '0050-01-01T10:00:00.000Z', shanghai: '0050-01-01T01:54:17.000Z' }, + { id: 'c0050s', cell: '0050/1/1 10:00', utc: '0050-01-01T10:00:00.000Z', shanghai: '0050-01-01T01:54:17.000Z' }, + { id: 'c0099', cell: '0099-12-31 23:59:59', utc: '0099-12-31T23:59:59.000Z', shanghai: '0099-12-31T15:54:16.000Z' }, + { id: 'c0100', cell: '0100-01-01 10:00', utc: '0100-01-01T10:00:00.000Z', shanghai: '0100-01-01T01:54:17.000Z' }, + { id: 'c2026', cell: '2026-07-15 10:00', utc: '2026-07-15T10:00:00.000Z', shanghai: '2026-07-15T02:00:00.000Z' }, + // A bare day is midnight UTC in every zone (#20534 already read it so). + { id: 'd0050', cell: '0050-01-01', utc: '0050-01-01T00:00:00.000Z', shanghai: '0050-01-01T00:00:00.000Z' }, +] as const; + +// --------------------------------------------------------------------------- +// The cell reader, on two hosts. +// --------------------------------------------------------------------------- + +const originalTz = process.env.TZ; +afterEach(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(['UTC', SHANGHAI])('[#20599] parseDateCell on a %s host', (host) => { + it.each(CELLS)('reads $cell in its own year', ({ cell, utc, shanghai }) => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + expect(parseDateCell(cell, 'datetime')).toBe(utc); + expect(parseDateCell(cell, 'datetime', 'UTC')).toBe(utc); + expect(parseDateCell(cell, 'datetime', SHANGHAI)).toBe(shanghai); + }); +}); + +// --------------------------------------------------------------------------- +// The routes, over a real engine. +// --------------------------------------------------------------------------- + +const OBJECT = 'import_year_below_100_20599'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20599', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + dt: { name: 'dt', type: 'datetime' as const, label: 'At' }, + }, +}; + +const MAPPING = { ID: 'id', At: 'dt' }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: Buffer[] = []; + const res: any = { + write: (c: unknown) => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))); return true; }, + end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + res._text = () => Buffer.concat(chunks).toString('utf8'); + return res; +} + +async function boot(timezone: string | undefined, engines: ObjectQL[]) { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }), true); + await engine.init(); + engine.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', ...(timezone ? { timezone } : {}) }); + rest.registerRoutes(); + const route = (method: string, path: string) => { + const found = rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any; + expect(found, `${method} ${path}`).toBeDefined(); + return found; + }; + const createRoute = route('POST', '/api/v1/data/:object'); + const exportRoute = route('GET', '/api/v1/data/:object/export'); + const importRoute = route('POST', '/api/v1/data/:object/import'); + const send = async (r: any, req: Record) => { + const res = makeRes(); + await r.handler({ params: { object: OBJECT }, ...req } as any, res); + return res; + }; + return { + engine, + create: (body: Record) => send(createRoute, { body }), + exportAs: (format: 'csv' | 'json') => send(exportRoute, { query: { format } }), + importBody: (body: Record) => send(importRoute, { body }), + }; +} + +/** The stored instant, as ISO. An offset-free string would be read in the host zone, so it fails instead. */ +function storedInstant(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'number') return new Date(value).toISOString(); + const s = String(value); + expect(s).toMatch(/(Z|[+-]\d{2}:?\d{2})$/); + return new Date(s).toISOString(); +} + +describe.each([ + ['no business timezone', undefined, 'utc'], + ['UTC', 'UTC', 'utc'], + ['Asia/Shanghai', SHANGHAI, 'shanghai'], +] as const)('[#20599] POST /import, %s', (_label, zone, column) => { + const engines: ObjectQL[] = []; + let stack: Awaited>; + let summary: any; + + beforeAll(async () => { + stack = await boot(zone, engines); + const csv = ['ID,At', ...CELLS.map((c) => `${c.id},${c.cell}`)].join('\r\n'); + summary = (await stack.importBody({ format: 'csv', csv, mapping: MAPPING, writeMode: 'insert' }))._json; + }); + + afterAll(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } + }); + + it('imports every row', () => { + expect(summary).toMatchObject({ total: CELLS.length, ok: CELLS.length, errors: 0 }); + }); + + it.each(CELLS)('stores $cell as the instant it names', async (c) => { + const back = await stack.engine.findOne(OBJECT, { where: { id: c.id } }); + expect(storedInstant(back?.dt)).toBe(c[column]); + }); +}); + +/** + * The export spells a year below 1000 unpadded, and the import refuses that + * cell: #20602's half of the round trip. Pad it as #20602's export will, so + * this measures the import half. A four-digit year is left alone. + */ +const padExportYear = (cell: string) => cell.replace(/^(\d{1,3})-/, (_m, y: string) => `${y.padStart(4, '0')}-`); + +const ROUND_TRIP_YEARS = ['0001', '0050', '0099', '0100', '2026'] as const; +const ROWS = ROUND_TRIP_YEARS.map((year) => ({ id: `y${year}`, dt: `${year}-01-01T10:00:00.000Z` })); + +describe.each([undefined, SHANGHAI, NEW_YORK] as const)('[#20599] GET /export then POST /import, business timezone %s', (zone) => { + describe.each(['csv', 'json'] as const)('format %s', (format) => { + const engines: ObjectQL[] = []; + let source: Awaited>; + let target: Awaited>; + let summary: any; + + beforeAll(async () => { + source = await boot(zone, engines); + for (const row of ROWS) { + const created = await source.create(row); + expect(created._status ?? 201, `create ${row.id}`).toBe(201); + } + const exported = await source.exportAs(format); + expect(exported._status ?? 200).toBe(200); + + let body: Record; + if (format === 'json') { + const rows = JSON.parse(exported._text()).map((r: any) => ({ ...r, dt: padExportYear(String(r.dt)) })); + expect(rows).toHaveLength(ROWS.length); + body = { format, rows, writeMode: 'insert' }; + } else { + const [header, ...lines] = exported._text().split('\r\n').filter((l: string) => l.length > 0); + expect(header).toBe('ID,At'); + expect(lines).toHaveLength(ROWS.length); + const csv = [header, ...lines.map((l: string) => { + const [id, dt] = l.split(','); + return `${id},${padExportYear(dt)}`; + })].join('\r\n'); + body = { format, csv, mapping: MAPPING, writeMode: 'insert' }; + } + + target = await boot(zone, engines); + summary = (await target.importBody(body))._json; + }); + + afterAll(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } + }); + + it('re-imports every row', () => { + expect(summary).toMatchObject({ total: ROWS.length, ok: ROWS.length, errors: 0 }); + }); + + it.each(ROWS)('stores $dt back as itself', async (row) => { + const back = await target.engine.findOne(OBJECT, { where: { id: row.id } }); + const src = await source.engine.findOne(OBJECT, { where: { id: row.id } }); + expect(storedInstant(src?.dt)).toBe(row.dt); + expect(storedInstant(back?.dt)).toBe(row.dt); + }); + }); +}); 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 new file mode 100644 index 00000000000..e62e605ab55 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20599] This package's own date-bucket sites read a year from 0001 to 0099 + * as itself. + * + * - The preview evaluator's `week` key (`bucketDate`) built the day with + * `Date.UTC(year, …)`, which reads a year from 0 to 99 as 1900 + year, so a + * 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). + * + * 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, + * in UTC and in Asia/Shanghai where the site takes a zone. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { bucketDate } from '../preview-evaluator.js'; +import { bucketKeyAtOrdinal, bucketOrdinalOfDay } from '../dataset-executor.js'; + +// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read +// UTC components only, and the answer must not move with the process zone. +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; +const originalTz = process.env.TZ; +afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(HOSTS)('on a %s host', (host) => { + beforeEach(() => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + }); + + describe('[#20599] preview bucketDate(week) names the Monday of a day in 0001..0099', () => { + it.each([ + ['0001-01-03T10:00:00.000Z', '0001-01-01'], + ['0050-01-01T10:00:00.000Z', '0049-12-27'], + ['0050-06-15T10:00:00.000Z', '0050-06-13'], + ['0099-12-31T10:00:00.000Z', '0099-12-28'], + ['0100-01-06T10:00:00.000Z', '0100-01-04'], + ['2026-06-17T10:00:00.000Z', '2026-06-15'], + ])('%s in UTC is the week of %s', (instant, monday) => { + expect(bucketDate(instant, 'week')).toBe(monday); + expect(bucketDate(instant, 'week', 'UTC')).toBe(monday); + }); + + it.each([ + // Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai. + ['0050-01-02T20:00:00.000Z', '0049-12-27', '0050-01-03'], + ['0001-01-07T20:00:00.000Z', '0001-01-01', '0001-01-08'], + ['0099-12-31T20:00:00.000Z', '0099-12-28', '0099-12-28'], + ['2026-06-14T20:00:00.000Z', '2026-06-08', '2026-06-15'], + ])('%s is the week of %s in UTC and of %s in Asia/Shanghai', (instant, utc, shanghai) => { + expect(bucketDate(instant, 'week')).toBe(utc); + expect(bucketDate(instant, 'week', 'Asia/Shanghai')).toBe(shanghai); + }); + }); + + 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); + + it.each([ + ['0001-06-15', 1], + ['0050-06-15', 50], + ['0099-06-15', 99], + ['0100-06-15', 100], + ['2026-06-15', 2026], + ])('%s is in year %i, month 6, quarter 2, and on its own day', (day, year) => { + expect(bucketOrdinalOfDay(day, 'year')).toBe(year); + expect(numbers(bucketKeyAtOrdinal(bucketOrdinalOfDay(day, 'year'), 'year'))).toEqual([year]); + expect(numbers(bucketKeyAtOrdinal(bucketOrdinalOfDay(day, 'quarter'), 'quarter'))).toEqual([year, 2]); + expect(numbers(bucketKeyAtOrdinal(bucketOrdinalOfDay(day, 'month'), 'month'))).toEqual([year, 6]); + expect(numbers(bucketKeyAtOrdinal(bucketOrdinalOfDay(day, 'day'), 'day'))).toEqual([year, 6, 15]); + }); + + it.each([ + ['0001-01-01', [1, 1]], + ['0050-01-01', [49, 52]], + ['0050-06-15', [50, 24]], + ['0099-12-31', [99, 53]], + ['0100-01-04', [100, 1]], + ['2026-06-15', [2026, 25]], + ])('the week ordinal of %s mints its ISO week back', (day, week) => { + expect(numbers(bucketKeyAtOrdinal(bucketOrdinalOfDay(day, 'week'), 'week'))).toEqual(week); + }); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts b/packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts new file mode 100644 index 00000000000..e2f8a844d92 --- /dev/null +++ b/packages/triggers/trigger-schedule/src/time-relative-window-year-below-100.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20599] A time-relative window that lands in 0001..0099 is that day's + * window. `startOfUtcDay` / `endOfUtcDay` built both bounds with + * `Date.UTC(year, …)`, which reads a year from 0 to 99 as 1900 + year, so an + * offset reaching 0050-09-30 swept 1950-09-30 instead. They 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. The window is UTC calendar arithmetic by + * contract, with no reference zone to vary. + */ + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { computeDateWindows, computeWindowClaimScopes } from './index.js'; + +// Wed 2026-09-30 12:00 UTC. Each offset below is the day count from 2026-09-30 +// to the named day, proleptic Gregorian. +const NOW = new Date('2026-09-30T12:00:00.000Z'); + +const CASES = [ + [-739616, '0001-09-30'], + [-721719, '0050-09-30'], + [-703822, '0099-09-30'], + [-703457, '0100-09-30'], + [-272, '2026-01-01'], +] as const; + +// Every pin runs on a UTC host and on an Asia/Shanghai host: the sites read +// UTC components only, and the answer must not move with the process zone. +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; +const originalTz = process.env.TZ; +afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(HOSTS)('on a %s host', (host) => { + beforeEach(() => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + }); + + describe('[#20599] a time-relative window in 0001..0099 keeps its year', () => { + it.each(CASES)('offsetDays [%i] sweeps %s, and its claim scope names that day', (offset, day) => { + const desc = { object: 'c', dateField: 'd', offsetDays: [offset] }; + expect(computeDateWindows(desc, NOW)).toEqual([{ gte: `${day}T00:00:00.000Z`, lte: `${day}T23:59:59.999Z` }]); + expect(computeWindowClaimScopes(desc, NOW)[0]?.scope).toBe(`${day}:offset${offset}`); + }); + + it.each(CASES)('withinDays %i opens the lookback at %s', (within, day) => { + expect(computeDateWindows({ object: 'c', dateField: 'd', withinDays: within }, NOW)).toEqual([ + { gte: `${day}T00:00:00.000Z`, lte: '2026-09-30T23:59:59.999Z' }, + ]); + }); + }); +}); From 81c45375969236381c493e844008faf167e39348 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:05:41 +0000 Subject: [PATCH 3/3] chore(changeset): core minor for wallClockToUtcMs, analytics and trigger-schedule patch Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .changeset/20599-utc-instant-from-parts.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/20599-utc-instant-from-parts.md diff --git a/.changeset/20599-utc-instant-from-parts.md b/.changeset/20599-utc-instant-from-parts.md new file mode 100644 index 00000000000..9e45de7f852 --- /dev/null +++ b/.changeset/20599-utc-instant-from-parts.md @@ -0,0 +1,18 @@ +--- +"@objectstack/core": minor +"@objectstack/service-analytics": patch +"@objectstack/trigger-schedule": patch +--- + +fix(core): a date or time in the years 0001..0099 is read as written, not as 1900..1999, wherever a UTC instant is built from year / month / day / time parts + +`Date.UTC(year, …)` and `new Date(year, …)` read a year from 0 to 99 as 1900 + year. Core built its instants from parts that way, so every day of the years 0001..0099 (inside the supported range 0001..9999) landed in the 1900s at the sites below, with no error. + +- `@objectstack/core`: **new export** `wallClockToUtcMs(parts)`, the epoch milliseconds of a `WallClockParts` read as UTC. It is `Date.UTC` without the two-digit-year remap: `month` is 1-12, omitted time components are 0, and every component rolls over past its end as `Date.UTC` rolls it (`month: 13` is next January, `day: 0` the previous month's last day, `hour: 24` the next midnight). A `NaN` component gives `NaN`. Every site below now builds through it: + - `zonedWallClockToUtcMs` and `zonedDateStartToUtcMs`, the wall clock and the zone-offset read. The offset read also takes the zone's era, so a wall clock early on 0001-01-01 in a zone west of UTC, whose offset probe lands in year 0, reads right. + - `bucketKeyToCalendarRange` (`0050` spans 0050-01-01..0051-01-01, not 1950..1951; `0050-01-01` as a `day` key is no longer `null`) and `bucketDateKey`'s ISO week (0050-01-01 is in week 52 of 0049, not of 1949). + - The date macros: `{1976_years_ago}` resolves to `0050-09-30`, not `1950-09-30`. A macro that lands in 0001..0999 is now spelled with a four-digit year, as the `date` storage form spells it (`0055-06-15`, not `55-06-15`, which names no day). +- `@objectstack/service-analytics`: the preview evaluator's `week` key and the `compareTo` bucket alignment build their days through `wallClockToUtcMs`. +- `@objectstack/trigger-schedule`: a time-relative window's day bounds build through `wallClockToUtcMs`. + +What an author sees: `POST /api/v1/data/:object/import` stores the `datetime` cell `0050-01-01 10:00` as `0050-01-01T10:00:00.000Z`, and in `Asia/Shanghai` as `0050-01-01T01:54:17.000Z` (the zone's local mean time for that year). Before, it stored `1950-01-01T10:00:00.000Z` and `1950-01-01T02:00:00.000Z` and reported the row `ok`. Measured through the import route and read back through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16; the `2026-07-15 10:00` control is stored the same before and after. Every year from 0100 on builds exactly as before.