fix(driver-memory): a resolved dateRange window's upper bound is exclusive, so 'today' stops before tomorrow - #16966
Conversation
…exclusive
`parseDateRangeString('today')` returns the day's start instant and the NEXT
day's start instant, and the analytics call site compared that upper bound with
`$lte` — `nextUtcCalendarDay` widens only a bare `YYYY-MM-DD` and returns null
for an instant, so the half-open branch was never taken. `'today'` was one day
plus one instant long, two adjacent day windows overlapped at midnight, and a
row stamped there was counted in both.
The resolver now states whether the bound it produced is exclusive, and only a
window this driver resolved is compared with `$lt`. An explicit
`dateRange: [a, b]` is untouched: a caller-written timestamp end keeps the
inclusive reading this package publishes today, and a bare-day end keeps its
whole-day widening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
The `Intl.DateTimeFormat` fence dropped `fractionalSecondDigits` — it is not in this package's `lib` view of `DateTimeFormatOptions` — and reads the sub-second half off the window literal instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift Check3 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 — 8 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 6f190d4ffb339300ee4f5e91b99726280794b5ef && git checkout 6f190d4ffb339300ee4f5e91b99726280794b5ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 22c0279fc83224831581c0c83e0dd9e5fa82d5dd 17921d98d56479eab09e080a9207b75e58735557 && git checkout -B drift-repro 22c0279fc83224831581c0c83e0dd9e5fa82d5dd && git merge --no-ff 17921d98d56479eab09e080a9207b75e58735557
node scripts/docs-audit/affected-docs.mjs --json 22c0279fc83224831581c0c83e0dd9e5fa82d5dd |
Fixes #16179
Clause-②: no
Route 2, as triage settled it: fix what the relative tokens emit, and leave an explicit
dateRange: [a, b]alone.The defect, re-measured
parseDateRangeString('today')returns the day's start instant and the next day's start instant. The analytics call site compared that upper bound with$lte, becausenextUtcCalendarDaywidens only a bareYYYY-MM-DDand returnsnullfor a full timestamp, so the half-open branch was never taken.'today'was therefore one day plus one instant long: two adjacent day windows overlapped at midnight and a row stamped exactly there was counted in both.Driven end to end through
MemoryAnalyticsService.query()against the built package, the double-count as a number rather than an argument — the same row, the same table, asked on two consecutive days:The repair
parseDateRangeStringnow returns the window and what its upper bound means, and only a window this driver resolved is compared with$lt:'today'marks its end exclusive — it is where tomorrow begins, the one instant the day must not contain;[a, b]is marked inclusive, i.e. untouched;YYYY-MM-DDend keeps its whole-day widening to< nextUtcCalendarDay(day)(driver-memory / driver-mongodb:裸日期$lte上界在 datetime 值上同样丢当天数据(#3777 的非 SQL 驱动对齐) #4042; the SQL twin is dashboard 的日期区间上界打在datetime列上丢失当天数据 —— 默认配置即命中 #3777);last N …leg keeps$lte: its end is the current instant, a moment the window reaches rather than one it stops before.The union's two arms are discriminated by
Array.isArrayone line above the bound construction, so the token path and the explicit-array path never had to share an answer. ⇒ the escalation clause did not fire.YYYY-MM-DDend — is not available on this path, and its failure would have been silent.boundary()renders the query timezone's midnight instant; a bare day would be widened and then cut atT00:00:00Z, i.e. at UTC midnight, undoing #16042 for every non-UTC caller. Measured onAsia/Shanghai: the correct window ends at2026-09-06T16:00:00.000Z, and the bare-day route would end it eight hours late.⭐ The control: the explicit array is identical to the character
Triage's second acceptance half is a measurement, not an assertion. The card's own three-row fixture, through the built package's public entry, before and after the repair:
['2026-09-06T00:00:00.000Z','2026-09-07T00:00:00.000Z'], both storage forms2130b5b81b7965212fbcdb3bc3e6dad4f57c2b74c3372625dcad47021e525a46before and afterdiffexit 0'today', both storage forms00:00:00.000row is dropped, and only itThe published type surface is byte-identical too:
dist/index.d.tssha2562be0755c20d8eadf59938f00ddf78f97c69642c84607f6ef7935fff4a31ad476with the fix and withmemory-analytics.tsreverted toorigin/mainand rebuilt. No exported symbol, no option key — the carrier is unchanged.Ablation
Mutation:
endExclusive: true→falsein the'today'branch — the minimal restoration of the defect, leaving the array path alone. On-disk proof each leg: occurrence counts of the exact texts flipped 1→0 and 0→1, and the blob hash moved off the HEAD blob (0315a768…→22c7fdaf…). Restore verified by blob-hash equality and an emptygit diff HEAD, under anEXIT INT TERMtrap on absolute paths.Predicted direction: red on the token leg, green on the explicit-array leg. Observed, at HEAD
17921d98d5:The 18 are exactly the token assertions (8 boundary-row + 8 two-arms-differ + 2 partition). The 12 that stay green include all 8 explicit-array control cases; re-run alone under the same mutation they are
8 passed | 22 skipped. That is the route-1 tripwire: a repair that made$ltunconditional reddens there, not here.--reporter=basic, which vitest 4 tried to load as a custom reporter module (ERR_LOAD_URL), so the suite never started. Re-run without it.Verification
Exit codes captured by redirect-then-
$?, never across a pipe.COVERAGE — gates derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final head17921d98d5(change set: the 3 files in this PR; identical to the derivation taken before the last commit). All 55 invoked; reconciled with--ran:VERDICTS — stated separately from coverage, because they are not the same fact:
PREREQUISITE NOT MET— ⛔ neither is a pass:pnpm check:dual-build-cjs-loads(81 packages have nodist/) andpnpm check:type-check-debt(27 workspace deps of the ledgered packages have no built type entry point). Both demand a whole-monorepo build closure, which is a repo-wide run CI owns; declared to CI, and no ledger number is read from either.Package-level, under the shared verify lock:
pnpm --filter @objectstack/driver-memory typechecktsc --noEmit+tsconfig.typecheck.json)pnpm --filter @objectstack/driver-memory testTest Files 46 passed (46)·Tests 1148 passed | 15 todo (1163)pnpm --filter '@objectstack/driver-memory...' buildpnpm lint(repo-wide,eslint . --no-inline-config)The new pin file is reached by the package's
tsconfig.json(tsc --listFiles), so itstypecheckis not blind to it.Anchors, re-measured, with the tree named
Every anchor below was taken on the worktree
/home/user/objectstack-issue-16179at97adce2faa9d27d7811f1f299f5ed806a467f624, which isorigin/main. Triage did not re-check any of them.memory-analytics.ts:760—const nextDay = nextUtcCalendarDay(end);:766calendar-day.ts:42— the documented contract:42,export function nextUtcCalendarDay(value: unknown): string | nullcalendar-day.test.ts:29-31— the pin:29-31, the three instant refusals insideit('refuses instants …')at:28'today'tooThe premise holds.
'today'is inDATE_RANGE_PRESETS, so it survives the closed vocabulary and reaches the resolver — the token path is live.验收备注
dateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 — ⛔ not touched here, ⛔ not merged in.parseDateRangeStringmatchesrange.startsWith('last ')(a space), while the declared vocabulary spells its presetslast_7_days/last_30_days/last_90_days. Every preset except'today'therefore falls to the[range, range]fallback today. That is squarely inside Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322's declared scope (driver half ofdriver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041, currentlypm:blocked), so no new card. Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 is not addressed by this PR and stays open.:760anchor is 6 lines stale onorigin/main; the shape it quotes is correct.check:dual-build-cjs-loadsandcheck:type-check-debtboth need a whole-monorepo build before they can render any verdict at all. Infrastructure prerequisite, not a defect.driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041 (an unparseabledateRangematching every row — this PR is about a parsed window's boundary),driver-memoryanalytics acceptstimeDimensions[].granularityand never buckets by it — one group per distinct timestamp #16178 (grouping),driver-memoryanalytics resolves a stringdateRangewithout ever reading the declaredAnalyticsQuery.timezone#16042 (which calendar anchors the window). None of them is addressed here.Changeset
patch, measured rather than assumed:@objectstack/driver-memoryis17.3.0with realexports,publishConfig.access: public, noprivate: true, and it sits in.changeset/config.json'sfixedgroup withignore: []. It publishes ⇒ a bug fix in it takes apatchchangeset and ⛔ neverskip-changeset— AGENTS.md:1036onorigin/main.Generated by Claude Code