Repository navigation
Commit ad067ad
fix(service-analytics): compareTo resolves its comparison window on the reference calendar, not UTC (#18596)
Fixes #18245
Clause-②: no
A non-UTC calendar preset under `compareTo` produced a comparison window
one day too wide, silently, under an ordinary `200`.
## Mechanism
`analytics-date-range.ts` renders both bounds of the ten calendar
presets through `zonedDateStartToUtcMs`, so a lowered window is a pair
of **instants** that open and close at the *reference zone's* midnight.
`DatasetExecutor` then projected those instants onto **UTC** days,
through a local `parseUTC` / `toISODate` pair it carried itself.
Whenever the zone's midnight is not UTC's, the projection moves a
boundary — and it moves it in **opposite directions** either side of the
meridian, which is the signature of a day-boundary projection and not of
an off-by-one constant.
MEASURED through `DatasetExecutor.execute`, `this_month` + `compareTo: {
kind: 'previousYear' }`, frozen at `2026-09-09T12:00:00Z` (noon, so all
three zones read the same calendar day):
| timezone | before (`e0d05538c`) | after (`dfb909bd0`) |
|---|---|---|
| `UTC` | `['2025-09-01','2025-09-30']` — 30 days, the positive control
| `['2025-09-01','2025-09-30']` — 30 days, unchanged |
| `Asia/Shanghai` | `['2025-08-31','2025-09-30']` — 31 days, **starts a
day early** | `['2025-09-01','2025-09-30']` — 30 days |
| `America/New_York` | `['2025-09-01','2025-10-01']` — 31 days, **ends a
day late** | `['2025-09-01','2025-09-30']` — 30 days |
The UTC row is committed as a **lit positive control**, not a comment: a
fix that shifted a constant would green one non-UTC row, break UTC, and
read as a pass to any suite that measured a single zone. Two further
zones at the extremes of the offset range (`Pacific/Kiritimati` at +14,
`Pacific/Niue` at −11) are pinned in the same file.
Not a regression of #18241. Before it this input was a hard
`DATASET_INVALID` / 400 — the preset arm could not produce a window at
all. What changed is **reachability**: a refusal became a
slightly-too-wide answer for non-UTC orgs and a correct one for UTC
orgs.
## The fix is a deletion, and `packages/core` takes zero edits
The local `parseUTC` / `toISODate` pair is **removed**. Nothing
timezone-aware is written in its place — the shared vocabulary already
exports both directions and this package already calls one of them one
file over (`analytics-service.ts:23` / `:1623`):
- **A bare `YYYY-MM-DD` is a calendar day, not an instant.** The year
shift, the `previousPeriod` length and the bucket ordinals are calendar
arithmetic that no zone changes, so they run on the **zone-free UTC
proxy** `zonedDateStartToUtcMs` yields for an unset zone — the pattern
`analytics-date-range.ts`'s own header prescribes ("anchors on the
reference timezone's calendar day and does its arithmetic on a UTC
proxy").
- **One seam reaches a reference zone**: turning the lowered window's
instants into days. It calls `@objectstack/core`'s `bucketDateKey` at
`'day'` — the same `Intl`-backed extraction the runtime's grouping
labels rows with, and the exact inverse of the `zonedDateStartToUtcMs`
that produced those bounds — threaded with the timezone `buildQuery`
already resolves the primary pass in.
Threading a zone into the *arithmetic* instead would put DST in the
middle of a year shift: `2026-03-09` is `04:00Z` in `America/New_York`
(EDT) and that same instant a year earlier reads `2025-03-08T23:00` EST
— a different day. That is why the zone stops at the seam.
Zero edits in `packages/core`, zero in `packages/spec`, and zero change
to this file's exported surface (18 `export` lines before, 18 after, no
diff) — hence `Clause-②: no`.
## Ablation — a green alone is not evidence
The fix was committed first, then the UTC projection was put back at
that one seam (the two `timezone` arguments dropped from
`inclusiveCalendarDayWindow`), proven on disk before the run (injected
spellings present 1/1, replaced spellings remaining 0/0, blob hash moved
`9bd0156…` to `aef7dd9…`), and restored afterwards to byte-identical
`9bd0156…` with `git diff HEAD` empty:
```
× Asia/Shanghai — does not start a day early (was 2025-08-31, 31 days)
AssertionError: the zone is EAST of UTC, so its midnight is the PREVIOUS UTC day:
expected '2025-08-31' to be '2025-09-01'
× America/New_York — does not end a day late (was 2025-10-01, 31 days)
AssertionError: the zone is WEST of UTC, so its next-month midnight is the NEXT UTC day:
expected '2025-10-01' to be '2025-09-30'
× every reference zone reports the same 30-day window
AssertionError: expected { UTC: 30, 'Asia/Shanghai': 31, …(3) }
to deeply equal { UTC: 30, 'Asia/Shanghai': 30, …(3) }
Tests 3 failed | 1 passed (4)
```
The UTC control stayed green under ablation, which is the half that
distinguishes this defect from a constant. The test subject is imported
by a relative specifier (`../dataset-executor.js`), so vitest reads the
mutated source directly — there is no `dist` leg in this ablation's
resolution path, and the package declares no vitest alias.
## Verification
All at `dfb909bd0`, exit codes captured to disk before being read.
- `pnpm --filter '@objectstack/service-analytics^...' build` — exit 0.
- `pnpm --filter @objectstack/service-analytics test` — **112 files,
2403 tests passed**.
- `pnpm --filter @objectstack/service-analytics typecheck` — exit 0;
`tsc --noEmit --listFiles` confirms the new test file is in the program.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0. Whole
population, no narrowing claimed.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`
derived **61** families; all 61 were run and reconciled with `--ran`
carrying each exit code. **57 green.** Three exited 3 (`PREREQUISITE NOT
MET`, i.e. NOT MEASURED, not a pass and not a finding):
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt` — each needs a whole-repo `dist`, which CI
builds.
- One exited 1, **pre-existing and not this diff's**:
`check:cross-package-test-inputs` flags
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
into `packages/spec/dist/`. Control: reverting all three of this PR's
paths to the merge base, leaving the same tree and the same on-disk
`packages/spec/dist`, reproduces the identical failure. It is a local
build-state artefact — the gate walks a gitignored directory — and a
sibling checkout with a different `packages/spec/dist` exits 0.
## Acceptance notes
Observed while in the file, deliberately **not** changed here:
- **The explicit-array arm's timestamp bounds are still read on the UTC
calendar.** `['2026-09-01T00:00:00Z', …]` bypasses the seam entirely and
reaches `shiftRange` unprojected, so a bound falling between the zone's
midnight and UTC's would shift the same way the preset arm did.
**Unmeasured** — and the arm's own contract states that a bare day
versus a full timestamp is a per-face calendar translation (#3777 /
#4042) that its arity rule does not touch, so it is not obviously a
defect rather than a declared boundary. Successor: the next card in the
analytics `dateRange` lane (#17015 / #17124 / #17596) that opens
`date-range-array-arm.ts`.
- **`isoWeekKeyOfUtcMs` is a hand-copy of core's ISO-week rule.** It is
documented as deliberate (core's `isoWeekLabelFromCalendarDay` is
module-private) and held honest by a round-trip pin against the exported
`bucketKeyToCalendarRange`, so it is a recorded decision rather than
drift. No successor needed.
Neither is filed: the first is not measured, the second is declared.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0bd7dae commit ad067ad
3 files changed
Lines changed: 275 additions & 34 deletions
File tree
- .changeset
- packages/services/service-analytics/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
Lines changed: 143 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
0 commit comments