Skip to content

fix(driver-memory): analytics dateRange resolves on the UTC calendar, not the process-local one (#15825) - #16047

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15825-memory-analytics-utc-window
Sep 5, 2026
Merged

fix(driver-memory): analytics dateRange resolves on the UTC calendar, not the process-local one (#15825)#16047
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-15825-memory-analytics-utc-window

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes #15825

MemoryAnalyticsService.query() lowers a string dateRange through parseDateRangeString(), and that function built its window on the local calendar and rendered it as UTC. There were two independent defects, and each is repaired and pinned separately here.

Defect 1 — the window boundary was LOCAL midnight

new Date(y, m, d) constructs local midnight; toISOString() renders that instant in UTC. So in any process not sitting at UTC, the 'today' bucket was the local day expressed as a UTC range. ⛔ Wrong on every day of the year, with no DST transition needed. Reproduced before touching anything, clock at 2026-09-05T20:51Z, by transcribing the function verbatim from origin/main:

TZ 'today' window produced the UTC day it should be agrees?
UTC 2026-09-05T00:00Z2026-09-06T00:00Z same
Asia/Shanghai 2026-09-05T16:00Z2026-09-06T16:00Z 2026-09-05T00:00Z2026-09-06T00:00Z
America/Los_Angeles 2026-09-05T07:00Z2026-09-06T07:00Z 2026-09-05T00:00Z2026-09-06T00:00Z
Europe/Berlin 2026-09-04T22:00Z2026-09-05T22:00Z 2026-09-05T00:00Z2026-09-06T00:00Z
Asia/Kolkata 2026-09-05T18:30Z2026-09-06T18:30Z 2026-09-05T00:00Z2026-09-06T00:00Z

(The card predicted 08:00Z for Los Angeles; 07:00Z is the same defect in PDT rather than PST — both are pinned, 2026-09-05 and 2026-01-15.)

Defect 2 — the last N ... legs mixed two calendars

setDate(getDate() - n) is local arithmetic and toISOString() is a UTC rendering. setDate preserves wall-clock time, so the instant moves n × 24h only while every local day in the window is 24 hours long; across a DST transition it moves 23h or 25h and the window start slips an hour. setMonth / setFullYear are the same class — and can move the answer by a whole day: at America/New_York with the clock at 2026-01-01T12:00Z, last 1 month started at 2025-12-02T00:00Z instead of 2025-12-01T00:00Z.

⭐ The two are independent — demonstrated, not asserted

setDatesetUTCDate does not fix defect 1, and Date.UTC alone does not fix defect 2. Each half was ablated on its own, in one shell, with the mutation proven on disk by anchored grep counts before measuring and the restore proven afterwards (blob hash == HEAD blob and git diff HEAD empty):

ablation (the other half left repaired) boundary pin DST pin reading
revert boundary only (Date.UTCnew Date(y,m,d)) 11 of 17 red ⛔ 12 of 21 red setUTCDate alone leaves defect 1 in place
revert arithmetic only (setUTCDatesetDate) green 13 of 21 red Date.UTC alone leaves defect 2 in place

That green cell is the whole point: the boundary pin is insensitive to defect 2, so the two defects are separable and each pin measures its own.

The mutated file is imported by both pins as a same-package relative specifier (./memory-analytics.js), and this package's vitest.config.ts aliases only @objectstack/* → source, so the ablation reaches source; no dist rebuild sits in its path and the "unbuilt ablation stays green" failure mode does not apply. Both ablation legs carried a trap … EXIT INT TERM with absolute paths — and it earned its keep: a first run died on a shell bug mid-mutation, and the trap left the tree byte-exact (verified by hash).

Why UTC, and not "any consistent calendar"

The rest of the platform resolves a bare date to the UTC day: @objectstack/core's {today} filter-token macro builds its reference day as new Date(Date.UTC(year, month - 1, day)) and falls back to UTC parts when the context carries no timezone, and {TODAY()} in flow templates resolves to the UTC day (#14852 repaired the identical two-calendar shape there — its template-date-offset-dst.test.ts is on origin/main and was used as the model for the DST pin's structure). UTC is also the terminal fallback of the engine's own resolution chain, selection.timezone ?? context.timezone ?? 'UTC' (ADR-0053 Phase 2). Before this change the same analytics question asked through this path and through a flow token could select different rows in one deployment — restoring that agreement, not the hour count, is what earns the p2.

Scope — spelling fix only

⛔ The parser is not replaced, per triage's ruling on the card: 「先做拼写修复(UTC 化两个缺陷),⛔ 不要顺手换掉整个 parser」. Its vocabulary, its [range, range] fallback and the shape of the emitted $match are untouched. Triage asked that anyone proposing a replacement first measure which range forms actually reach it — that measurement was taken and filed rather than acted on:

Neither is repaired here. Sibling card #15824 (HistoryCleanupManager) is the same class in a different file and was ⛔ not absorbed.

Clause-② — measured no

Built at head → swapped only the changed source back to origin/main (2024eca4f) → rebuilt → diffed the built dist/index.d.ts → restored byte-exact and proved it (worktree and index):

dist/index.d.ts sha (head): 09684929832de0380c056b073ccef8a195fe8b0a
dist/index.d.ts sha (base): 09684929832de0380c056b073ccef8a195fe8b0a   -> IDENTICAL, 50852 bytes both

No exported symbol or signature moves. Separately on the other half of the test — no request is newly accepted or rejected: parseDateRangeString is a private method, it enters the same two branches on exactly the same inputs, the fallback is untouched, and AnalyticsQuerySchema is unchanged. What moved is the answer, not the surface. Bump is nevertheless minor, matching how this package levels an answer-changing analytics repair — the act over the commit type.

Verification

Gate union re-derived after the final commit with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (provenance line checked: derived from this repo's tree) and run at cb1cb6e54, the head of this branch:

  • 53 of 53 gate families green. 51 on the first pass; check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3 — the gates' own text says this is ⛔ not a pass and ⛔ not a finding), so the workspace closure was built (turbo run build, 71/71 successful) and both then ran green: ✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 66 package(s) load and check-type-check-coverage --re-measure: OK — 12 ledger entr(ies) re-measured in 106.7s, 140 raw tsc error(s) total, none above its recorded number.
  • pnpm --filter @objectstack/driver-memory test43 files, 1096 tests, all pass.
  • pnpm --filter @objectstack/driver-memory typecheck — exit 0. --listFiles confirms both new pins are inside the tsc program (include: src/**/*, no *.test.ts exclusion), so that green is a reading about them and not a vacuous pass.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, no findings. Run in full rather than narrowed.

Every exit code above was captured before any pipe, and each gate's own verdict line is quoted rather than a bare $?.

Notes for review

  • ⛔ A DST regression test that runs only at TZ=UTC asserts nothing — the two spellings are indistinguishable there. Both pin files therefore fake the zone and the instant, and each carries an inline control that evaluates the old spelling and asserts it disagrees, so a green run cannot be confused with "these cells stopped being live".
  • The 13 DST cells were measured, not guessed — taken from a 9-zone × 366-day × 4-unit sweep of 2026. Both hemispheres, both transition directions, all four legs, and two zones whose offset is not a whole hour (America/St_Johns −03:30, Pacific/Chatham +12:45).
  • The DST pin's oracle is timezone-invariance (the TZ=UTC run is the reference answer, since both spellings coincide there) rather than a re-derived expected value — for month / year there is no offset-free definition, and re-deriving one would just be the fix written twice. Where a definition does exist (day / week) it is asserted as well.

🤖 Generated with Claude Code

Generated by Claude Code


Generated by Claude Code

…r, not the process-local one (#15825)

`parseDateRangeString()` built its window on the LOCAL calendar and rendered
it as UTC. Two independent defects, both repaired here:

1. The boundary was `new Date(y, m, d)` — LOCAL midnight — rendered by
   `toISOString()` as UTC. Wrong on every day of the year in every non-UTC
   process, no DST transition needed: measured, the `'today'` bucket ran from
   the previous 16:00Z at `Asia/Shanghai` and from 07:00Z at
   `America/Los_Angeles`.
2. The `last N ...` legs did day/week/month/year arithmetic on the LOCAL
   calendar and rendered it on the UTC one, so the window start slipped an
   hour across a DST transition — and a whole day on the `month` leg.

Neither repair fixes the other, so each is pinned by its own file and each was
ablated separately. The DST file cannot go red at `TZ=UTC`, where the two
spellings are indistinguishable — which is why CI never reddened on this.

UTC is the target calendar because the rest of the platform already resolves a
bare date to the UTC day (`{today}`, `{TODAY()}`), and disagreeing with it let
one deployment answer the same question two ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2e357650306335f7fdb8939d16edce4c96b48831packageMentionDocs.

Which tree this was computed on

This run read content/docs from faadf3dbc7c68435b224d907e3e131aecb98cdff — the merge of head cb1cb6e54f8531afb8d038e84370ffdc4e4cc0ca into base 2e357650306335f7fdb8939d16edce4c96b48831, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin faadf3dbc7c68435b224d907e3e131aecb98cdff && git checkout faadf3dbc7c68435b224d907e3e131aecb98cdff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e357650306335f7fdb8939d16edce4c96b48831 cb1cb6e54f8531afb8d038e84370ffdc4e4cc0ca && git checkout -B drift-repro 2e357650306335f7fdb8939d16edce4c96b48831 && git merge --no-ff cb1cb6e54f8531afb8d038e84370ffdc4e4cc0ca

node scripts/docs-audit/affected-docs.mjs --json 2e357650306335f7fdb8939d16edce4c96b48831

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 5, 2026 22:09
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 5f4f1f6 Sep 5, 2026
35 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15825-memory-analytics-utc-window branch September 5, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants