From cb2587a92ea548dc94ce57775ea206aa205fa666 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:43:30 +0000 Subject: [PATCH] fix(service-automation): resolve `{TODAY() +/- n}` on one calendar, not two (#14852) The offset branch of the flow template resolver did its day arithmetic on the LOCAL calendar (`getDate` / `setDate`) and rendered the result on the UTC one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the instant by exactly n x 24h only while every local day in the window is 24 hours long. Across a spring-forward that window is 23 hours and across a fall-back 25; when the resulting hour of slack crosses a UTC midnight, the rendered date is a day early (spring-forward) or a day late (fall-back). Spell the branch on one calendar - UTC, the same one both returns already render on. The bare `{TODAY()}` / `{NOW()}` forms never entered this branch and do not move; the offset forms now agree with them. This introduces no timezone concept. Measured over 34 zones x every 30 minutes of 2026 x offsets {+1, -1} (1,191,360 instant-offset pairs): the mixed spelling disagrees with the UTC day in 190 of them across 24 DST-observing zones, the new spelling in none. `template-date-offset-dst.test.ts` pins 14 measured red cells, each an instant that satisfies both conditions the flip needs at once - the local day shift straddles the zone's transition, and the hour of slack crosses a UTC midnight. Each cell carries an inline control asserting the old spelling DISAGREES there, so a green run cannot be read as "the fix works" when it really means "these instants are not in a transition window". The oracle in `template-functions.test.ts` was re-spelled on one calendar for the same reason: it had re-stated the defect it was checking against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .changeset/today-offset-one-calendar.md | 13 + .../builtin/template-date-offset-dst.test.ts | 246 ++++++++++++++++++ .../src/builtin/template-functions.test.ts | 9 +- .../src/builtin/template.ts | 15 +- 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 .changeset/today-offset-one-calendar.md create mode 100644 packages/services/service-automation/src/builtin/template-date-offset-dst.test.ts diff --git a/.changeset/today-offset-one-calendar.md b/.changeset/today-offset-one-calendar.md new file mode 100644 index 0000000000..60babce8b2 --- /dev/null +++ b/.changeset/today-offset-one-calendar.md @@ -0,0 +1,13 @@ +--- +"@objectstack/service-automation": patch +--- + +Flow templates: `{TODAY() + n}` and `{TODAY() - n}` now do their day arithmetic on the same calendar they render on (UTC), so the resolved date no longer lands a day off across a DST transition. + +The offset branch of the template resolver shifted the day on the **local** calendar (`getDate` / `setDate`) and then rendered the result on the **UTC** one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the underlying instant by exactly n x 24 hours only while every local day in the window is 24 hours long. Across a spring-forward the window is 23 hours and across a fall-back 25, and when that one hour of slack crosses a UTC midnight the rendered date comes out a day early (spring-forward) or a day late (fall-back). + +The window is narrow — roughly one hour per DST-observing zone, twice a year — but the values written through it persist: a quote expiration, a follow-up date, a close date. Measured across 34 zones at every 30 minutes of 2026 for offsets `+1` and `-1` (1,191,360 instant-offset pairs), the old spelling disagreed with the UTC day in 190 of them, spread over 24 DST-observing zones; the new spelling disagrees in none. + +The same branch serves `{NOW() + n}`, which likewise now moves the instant by exactly n x 24 hours instead of preserving a wall-clock time across the transition. + +Nothing else moves. The bare `{TODAY()}` and `{NOW()}` forms never entered this branch and are byte-for-byte unchanged — they already resolved on UTC, and the offset forms now agree with them. This is not a timezone feature: these tokens remain timezone-unaware by design, and whether they should be is a separate question. diff --git a/packages/services/service-automation/src/builtin/template-date-offset-dst.test.ts b/packages/services/service-automation/src/builtin/template-date-offset-dst.test.ts new file mode 100644 index 0000000000..b08ae4d7fc --- /dev/null +++ b/packages/services/service-automation/src/builtin/template-date-offset-dst.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #14852 — the `{TODAY() +/- n}` / `{NOW() +/- n}` offset branch resolves on + * ONE calendar (UTC), the same one the bare forms already render on. + * + * ## Why this file cannot be written to run only at `TZ=UTC` + * + * The two spellings — local `setDate(getDate() + n)` and UTC + * `setUTCDate(getUTCDate() + n)` — are behaviourally INDISTINGUISHABLE at + * `TZ=UTC`, which is precisely why nothing in CI ever went red on the defect + * and why it shipped. Every case below therefore fakes BOTH halves of the + * environment: a DST-observing zone (`process.env.TZ`, re-read by V8 on the + * next `Date` operation) AND an instant whose day shift crosses that zone's + * own transition. + * + * ## The mechanism, stated once + * + * `setDate` preserves WALL-CLOCK time, so shifting the local calendar by n + * days moves the INSTANT by exactly n x 24h only while every local day in the + * window is 24 hours long. Across a spring-forward that window is 23 hours; + * across a fall-back, 25. The rendering is `toISOString()` — UTC. So a flip + * needs TWO conditions AT ONCE: + * + * 1. the local day shift straddles the transition -> the instant moves + * 23h or 25h instead of n x 24h; and + * 2. that one hour of slack crosses a UTC midnight -> the rendered DAY, not + * merely the instant, comes out wrong. + * + * Condition (2) is what pins the instants below to the UTC hour [00:00, 01:00) + * for a forward offset and [23:00, 24:00) for a backward one: those are the + * only hours where one hour of slack changes which UTC day you land on. + * Condition (1) is what pins the DAY to each zone's own transition. Miss + * either and the cell is GREEN against the broken code — a single-point sweep + * is exactly what let the identical two-calendar shape sit unnoticed in a + * hotcrm test helper for months (`objectstack-ai/hotcrm#1462`). + * + * NOTE the direction is not one-signed: spring-forward renders a day EARLY + * (23h falls short of the midnight it had to cross), fall-back renders a day + * LATE (25h overshoots one). Both are pinned. + * + * ## The inline control is load-bearing + * + * Each cell also evaluates the OLD mixed spelling directly and asserts it + * DISAGREES with the truth. Without that, a green run would be ambiguous + * between "the fix works" and "these instants are not actually in a transition + * window" — the second being the failure mode that hid this bug. With it, the + * file proves its own instants are live before it credits the fix. + * + * ## Deliberately NOT pinned here + * + * Whether these tokens should be timezone-AWARE at all is a separate and + * larger question (#14852 explicitly does not propose it). The bare + * `{TODAY()}` resolves to the UTC day, and the controls below hold it there in + * every zone; this file only makes the offset branch AGREE with the bare one. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { interpolateString } from './template.js'; + +const ctx = {} as any; + +function tpl(expr: string, vars: Record = {}): unknown { + return interpolateString(`{${expr}}`, new Map(Object.entries(vars)), ctx); +} + +const REAL_TZ = process.env.TZ; + +/** Run `fn` with the process on `zone` and the clock frozen at `instant`. */ +function at(zone: string, instant: string, fn: () => T): T { + process.env.TZ = zone; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(instant)); + try { + return fn(); + } finally { + vi.useRealTimers(); + if (REAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = REAL_TZ; + } +} + +/** The defect, spelled out: day arithmetic on the LOCAL calendar, rendered on UTC. */ +function mixedCalendarSpelling(instant: string, n: number): string { + const d = new Date(instant); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +} + +/** + * The truth, computed from NEITHER spelling: the instant plus n x 24h, in UTC. + * `{TODAY() + n}` means "n days after the UTC day it is now", and a UTC day is + * always 24 hours — so this is the definition, not a second implementation. + */ +function utcDayShift(instant: string, n: number): string { + return new Date(Date.parse(instant) + n * 86_400_000).toISOString().slice(0, 10); +} + +interface Cell { + zone: string; + /** Frozen clock, always written in UTC. */ + instant: string; + offset: number; + /** The same instant as local wall clock, so the window is readable. */ + local: string; + kind: 'spring-forward' | 'fall-back'; +} + +/** + * Red cells — every one MEASURED, not guessed: each is an instant at which the + * mixed spelling actually disagrees with the truth in that zone, taken from a + * 34-zone x 30-minute sweep of 2026. Both hemispheres, both transition + * directions, and three zones whose standard offset is not a whole hour + * (St_Johns -03:30, Chatham +12:45, Adelaide +09:30 via Sydney's sibling rule) + * so a whole-hour assumption cannot hide in the fix. + */ +const DST_CELLS: Cell[] = [ + { zone: 'America/New_York', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 19:30 EST', kind: 'spring-forward' }, + { zone: 'America/New_York', instant: '2026-03-08T23:30:00Z', offset: -1, local: 'Sun 2026-03-08 19:30 EDT', kind: 'spring-forward' }, + { zone: 'America/New_York', instant: '2026-10-31T23:30:00Z', offset: 1, local: 'Sat 2026-10-31 19:30 EDT', kind: 'fall-back' }, + { zone: 'America/New_York', instant: '2026-11-02T00:30:00Z', offset: -1, local: 'Sun 2026-11-01 19:30 EST', kind: 'fall-back' }, + { zone: 'America/Los_Angeles', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 16:30 PST', kind: 'spring-forward' }, + { zone: 'America/St_Johns', instant: '2026-03-08T00:30:00Z', offset: 1, local: 'Sat 2026-03-07 21:00 NST', kind: 'spring-forward' }, + { zone: 'Europe/London', instant: '2026-03-29T00:30:00Z', offset: 1, local: 'Sun 2026-03-29 00:30 GMT', kind: 'spring-forward' }, + { zone: 'Europe/Berlin', instant: '2026-03-29T00:30:00Z', offset: 1, local: 'Sun 2026-03-29 01:30 CET', kind: 'spring-forward' }, + { zone: 'Asia/Jerusalem', instant: '2026-03-27T23:30:00Z', offset: -1, local: 'Sat 2026-03-28 02:30 IDT', kind: 'spring-forward' }, + { zone: 'Australia/Sydney', instant: '2026-10-03T00:30:00Z', offset: 1, local: 'Sat 2026-10-03 10:30 AEST', kind: 'spring-forward' }, + { zone: 'Australia/Adelaide', instant: '2026-10-03T00:30:00Z', offset: 1, local: 'Sat 2026-10-03 10:00 ACST', kind: 'spring-forward' }, + { zone: 'Pacific/Auckland', instant: '2026-09-26T00:30:00Z', offset: 1, local: 'Sat 2026-09-26 12:30 NZST', kind: 'spring-forward' }, + { zone: 'Pacific/Chatham', instant: '2026-09-26T00:30:00Z', offset: 1, local: 'Sat 2026-09-26 13:15 +1245', kind: 'spring-forward' }, + { zone: 'America/Santiago', instant: '2026-09-06T00:30:00Z', offset: 1, local: 'Sat 2026-09-05 20:30 -04', kind: 'spring-forward' }, +]; + +const label = (c: Cell) => `${c.zone} @ ${c.instant} (${c.local}) {TODAY() ${c.offset > 0 ? '+' : '-'} ${Math.abs(c.offset)}}`; + +describe('#14852 {TODAY() +/- n} resolves on one calendar, across DST transitions', () => { + for (const c of DST_CELLS) { + it(`${c.kind}: ${label(c)}`, () => { + const expr = `TODAY() ${c.offset > 0 ? '+' : '-'} ${Math.abs(c.offset)}`; + at(c.zone, c.instant, () => { + const expected = utcDayShift(c.instant, c.offset); + + // CONTROL FIRST — if this passes, the cell is not in a + // transition window and the assertion below would be vacuous. + // (It is the whole reason a TZ=UTC-only test is worthless here.) + expect( + mixedCalendarSpelling(c.instant, c.offset), + `${label(c)}: the mixed spelling must DISAGREE here, otherwise this cell pins nothing`, + ).not.toBe(expected); + + expect(tpl(expr), label(c)).toBe(expected); + }); + }); + } + + it('every red cell is live — the control disagrees in all of them', () => { + const live = DST_CELLS.filter((c) => + at(c.zone, c.instant, () => mixedCalendarSpelling(c.instant, c.offset) !== utcDayShift(c.instant, c.offset)), + ); + expect(live.length, 'a cell that no longer flips has stopped guarding the fix').toBe(DST_CELLS.length); + }); + + it('both directions are represented — a day EARLY and a day LATE', () => { + const dirs = new Set( + DST_CELLS.map((c) => + at(c.zone, c.instant, () => + mixedCalendarSpelling(c.instant, c.offset) < utcDayShift(c.instant, c.offset) ? 'early' : 'late', + ), + ), + ); + expect([...dirs].sort()).toEqual(['early', 'late']); + }); +}); + +describe('#14852 the offset branch serves NOW() too — same mutation, same calendar', () => { + for (const c of DST_CELLS.filter((x) => x.offset === 1).slice(0, 4)) { + it(`${c.zone} @ ${c.instant}: {NOW() + 1} is exactly +24h`, () => { + at(c.zone, c.instant, () => { + expect(tpl('NOW() + 1')).toBe(new Date(Date.parse(c.instant) + 86_400_000).toISOString()); + }); + }); + } +}); + +describe('#14852 the offset may come from a variable — same branch', () => { + it('{TODAY() + days} with days=1 lands on the UTC day too', () => { + const c = DST_CELLS[0]; + at(c.zone, c.instant, () => { + expect(tpl('TODAY() + days', { days: 1 })).toBe(utcDayShift(c.instant, 1)); + }); + }); +}); + +// ── Fences: what this change must NOT have moved ────────────────────────── + +describe('#14852 fences — the bare forms and the non-DST zones are untouched', () => { + const ALL_ZONES = [...new Set(DST_CELLS.map((c) => c.zone))]; + + it('bare {TODAY()} still resolves to the UTC day in every zone, including inside a transition window', () => { + for (const c of DST_CELLS) { + at(c.zone, c.instant, () => { + expect(tpl('TODAY()'), `${c.zone} @ ${c.instant}`).toBe(c.instant.slice(0, 10)); + }); + } + }); + + it('bare {NOW()} still renders the instant itself', () => { + for (const zone of ALL_ZONES) { + at(zone, '2026-03-08T00:30:00Z', () => { + expect(tpl('NOW()'), zone).toBe('2026-03-08T00:30:00.000Z'); + }); + } + }); + + it('zones that do not observe DST are unaffected — both spellings already agreed there', () => { + for (const zone of ['UTC', 'Asia/Shanghai', 'Asia/Kolkata', 'Australia/Perth']) { + for (const instant of ['2026-03-08T00:30:00Z', '2026-10-31T23:30:00Z', '2026-06-15T12:00:00Z']) { + at(zone, instant, () => { + expect(mixedCalendarSpelling(instant, 1), `${zone} @ ${instant}`).toBe(utcDayShift(instant, 1)); + expect(tpl('TODAY() + 1'), `${zone} @ ${instant}`).toBe(utcDayShift(instant, 1)); + }); + } + } + }); + + it('an ordinary instant in a DST zone is unaffected — the local day is 24h there', () => { + for (const zone of ALL_ZONES) { + at(zone, '2026-06-15T12:00:00Z', () => { + expect(mixedCalendarSpelling('2026-06-15T12:00:00Z', 1), zone).toBe('2026-06-16'); + expect(tpl('TODAY() + 1'), zone).toBe('2026-06-16'); + }); + } + }); + + it('a large offset still lands on the UTC day (the consumers use +90 and +120)', () => { + const c = DST_CELLS[0]; + at(c.zone, c.instant, () => { + expect(tpl('TODAY() + 90')).toBe(utcDayShift(c.instant, 90)); + expect(tpl('TODAY() + 120')).toBe(utcDayShift(c.instant, 120)); + }); + }); + + it('the process timezone is restored after every case', () => { + expect(process.env.TZ).toBe(REAL_TZ); + }); +}); diff --git a/packages/services/service-automation/src/builtin/template-functions.test.ts b/packages/services/service-automation/src/builtin/template-functions.test.ts index 00796f8607..5d4a25394c 100644 --- a/packages/services/service-automation/src/builtin/template-functions.test.ts +++ b/packages/services/service-automation/src/builtin/template-functions.test.ts @@ -202,8 +202,15 @@ describe('over-denial controls — the diagnostic is not a blanket refusal (#110 it('NOW()/TODAY() whole-token macros are unchanged', () => { expect(String(tpl('NOW()'))).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); expect(String(tpl('TODAY()'))).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // #14852: the ORACLE has to be spelled on one calendar too. With + // `setDate`/`getDate` it re-stated the very two-calendar defect it was + // checking against, so across a DST transition it would have FOLLOWED + // the implementation instead of catching it. Indistinguishable at + // TZ=UTC, which is why it read as correct for as long as it did; the + // transition windows themselves are pinned in + // `template-date-offset-dst.test.ts`. const plus90 = new Date(); - plus90.setDate(plus90.getDate() + 90); + plus90.setUTCDate(plus90.getUTCDate() + 90); expect(tpl('TODAY() + 90')).toBe(plus90.toISOString().slice(0, 10)); }); diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 77b7d4bb5e..0d2de693e3 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -224,7 +224,20 @@ function resolveToken(token: string, variables: VariableMap, context: Automation } } const now = new Date(); - if (offset) now.setDate(now.getDate() + sign * offset); + // ONE calendar, and it is UTC — the same one both returns render on + // two lines below (#14852). The old spelling did the day arithmetic + // on the LOCAL calendar (`getDate`/`setDate`) and rendered on the UTC + // one. That is accidentally equivalent only while the local day is 24 + // hours long: `setDate` preserves wall-clock time, so across a DST + // transition the instant moves 23h (spring-forward) or 25h + // (fall-back) instead of n x 24h, and the rendered date lands a day + // early or a day late for the one UTC hour whose shortfall/overshoot + // crosses midnight. Measured over 34 zones x every 30 minutes of 2026 + // x offsets {+1, -1} (1,191,360 pairs): the mixed spelling flips 190 + // of them across 24 DST-observing zones, this spelling flips none. + // Pinned by `template-date-offset-dst.test.ts`, which cannot go red + // at TZ=UTC -- there the two spellings are indistinguishable. + if (offset) now.setUTCDate(now.getUTCDate() + sign * offset); if (fn === 'NOW') return now.toISOString(); return now.toISOString().slice(0, 10); }