Repository navigation
fix(service-analytics): compareTo resolves its comparison window on the reference calendar, not UTC - #18596
Conversation
…s red Drives DatasetExecutor.execute with `this_month` + previousYear frozen at 2026-09-09 and reads the shifted comparison window off the wire. Reproduces the reported table byte-for-byte: UTC 30 days (lit positive control, green), Asia/Shanghai starts a day early, America/New_York ends a day late. Claude-Session: https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH Co-authored-by: Claude <noreply@anthropic.com>
…e calendar Deletes dataset-executor's local parseUTC/toISODate pair — a UTC-calendar duplicate of @objectstack/core's shared datetime vocabulary — and routes the compareTo day math through that vocabulary instead. A bare YYYY-MM-DD is a calendar day, so the year shift, the previous-period length and the bucket ordinals keep running on the zone-free UTC proxy zonedDateStartToUtcMs yields for an unset zone. The one seam a reference zone reaches is the projection of the lowered window's INSTANTS onto days, which now calls bucketDateKey at 'day' 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; threading it only into the lowering (as before) left the projection on UTC and misaligned the two grids by a day in opposite directions either side of the meridian. Claude-Session: https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e12e9730266a82d2c8edf76a09e84501e2d9a047 && git checkout e12e9730266a82d2c8edf76a09e84501e2d9a047
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e0d05538c0d0275728d25acfafbab259e23d0710 dfb909bd052b63982a795a1a00b27cbb1735daed && git checkout -B drift-repro e0d05538c0d0275728d25acfafbab259e23d0710 && git merge --no-ff dfb909bd052b63982a795a1a00b27cbb1735daed
node scripts/docs-audit/affected-docs.mjs --json e0d05538c0d0275728d25acfafbab259e23d0710 |
Fixes #18245
Clause-②: no
A non-UTC calendar preset under
compareToproduced a comparison window one day too wide, silently, under an ordinary200.Mechanism
analytics-date-range.tsrenders both bounds of the ten calendar presets throughzonedDateStartToUtcMs, so a lowered window is a pair of instants that open and close at the reference zone's midnight.DatasetExecutorthen projected those instants onto UTC days, through a localparseUTC/toISODatepair 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 at2026-09-09T12:00:00Z(noon, so all three zones read the same calendar day):e0d05538c)dfb909bd0)UTC['2025-09-01','2025-09-30']— 30 days, the positive control['2025-09-01','2025-09-30']— 30 days, unchangedAsia/Shanghai['2025-08-31','2025-09-30']— 31 days, starts a day early['2025-09-01','2025-09-30']— 30 daysAmerica/New_York['2025-09-01','2025-10-01']— 31 days, ends a day late['2025-09-01','2025-09-30']— 30 daysThe 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/Kiritimatiat +14,Pacific/Niueat −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/coretakes zero editsThe local
parseUTC/toISODatepair 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):YYYY-MM-DDis a calendar day, not an instant. The year shift, thepreviousPeriodlength and the bucket ordinals are calendar arithmetic that no zone changes, so they run on the zone-free UTC proxyzonedDateStartToUtcMsyields for an unset zone — the patternanalytics-date-range.ts's own header prescribes ("anchors on the reference timezone's calendar day and does its arithmetic on a UTC proxy").@objectstack/core'sbucketDateKeyat'day'— the sameIntl-backed extraction the runtime's grouping labels rows with, and the exact inverse of thezonedDateStartToUtcMsthat produced those bounds — threaded with the timezonebuildQueryalready resolves the primary pass in.Threading a zone into the arithmetic instead would put DST in the middle of a year shift:
2026-03-09is04:00ZinAmerica/New_York(EDT) and that same instant a year earlier reads2025-03-08T23:00EST — a different day. That is why the zone stops at the seam.Zero edits in
packages/core, zero inpackages/spec, and zero change to this file's exported surface (18exportlines before, 18 after, no diff) — henceClause-②: 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
timezonearguments dropped frominclusiveCalendarDayWindow), proven on disk before the run (injected spellings present 1/1, replaced spellings remaining 0/0, blob hash moved9bd0156…toaef7dd9…), and restored afterwards to byte-identical9bd0156…withgit diff HEADempty: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 nodistleg 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 --listFilesconfirms the new test file is in the program.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0. Whole population, no narrowing claimed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackderived 61 families; all 61 were run and reconciled with--rancarrying 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-repodist, which CI builds.check:cross-package-test-inputsflagspackages/cli/test/init-created-files-summary.e2e.test.tsdescending intopackages/spec/dist/. Control: reverting all three of this PR's paths to the merge base, leaving the same tree and the same on-diskpackages/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 differentpackages/spec/distexits 0.Acceptance notes
Observed while in the file, deliberately not changed here:
['2026-09-01T00:00:00Z', …]bypasses the seam entirely and reachesshiftRangeunprojected, 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 (dashboard 的日期区间上界打在datetime列上丢失当天数据 —— 默认配置即命中 #3777 / driver-memory / driver-mongodb:裸日期$lte上界在 datetime 值上同样丢当天数据(#3777 的非 SQL 驱动对齐) #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 analyticsdateRangelane (fix(analytics): lower the closed dateRange preset vocabulary once, and refuse the rest (#16322) #17015 / A ONE-elementdateRangearray is schema-valid and means two different windows:ObjectQLStrategydegenerates it to the point[start, start], the draft-preview face leaves the upper bound open #17124 / The shared dateRange conformance kit has no ARITY case, so an odd-sized array arm is governed nowhere — and driver-memory's cube face drops such a window entirely #17596) that opensdate-range-array-arm.ts.isoWeekKeyOfUtcMsis a hand-copy of core's ISO-week rule. It is documented as deliberate (core'sisoWeekLabelFromCalendarDayis module-private) and held honest by a round-trip pin against the exportedbucketKeyToCalendarRange, 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