Repository navigation
fix(service-analytics): lower a dateRange preset in runCompare instead of refusing it as an invalid date - #18241
Conversation
…d of refusing it `DatasetExecutor.runCompare` read the STRING arm of `dateRange` as the degenerate `[range, range]` fallback that #17015 removed from every other analytics face, so `parseUTC` was handed the preset NAME and a declared, honoured member of the closed vocabulary came back as DATASET_INVALID 400 invalid date in dateRange: "last_30_days" The diagnostic is FALSE, not merely unhelpful: `last_30_days` is exactly what the schema and the docs tell an author to write, so "invalid date" sends them to a repair that does not exist. Route both arms through one face lowering, `lowerDatasetCompareDateRange`, which calls the shared `resolveAnalyticsDateRangeString` for the string arm and `explicitDateRangeWindow` for the array arm, then project the resolved window onto this module's UTC calendar with `inclusiveUtcDayWindow` — the per-face calendar translation `shiftRange` and `alignedCompareBucketKey` need, with `endExclusive` honoured so the ten calendar presets are not a day too long. Out-of-vocabulary strings now raise the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope every sibling face raises, instead of this face's own `DATASET_INVALID`. The array arm is untouched, bound for bound. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
… conformance kit The dataset executor's `compareTo` window is the fourth analytics face in this package and the one #17015 never reached, so nothing measured it and nothing noticed it had kept the `[range, range]` fallback. Register it on `lowerDatasetCompareDateRange`, and add the end-to-end control block that makes the registration mean something: driven through `DatasetExecutor.execute`, a calendar preset lowers to the shared resolver's window with its exclusive end honoured (September stops on the 30th, not on October 1st) and shifts a year back; a rolling preset reaches NOW's own day and `previousPeriod` shifts by the window's own length; five distinct presets select five distinct comparison windows; an unrecognised string is refused on the ADR-0112 envelope rather than on `toThrow()`; and the caller's explicit window is still shifted bound for bound. Expectations are stated literally against the frozen clock and cross-checked against the shared resolver — never computed with `shiftRange`, which would restate the implementation and pass for any window at all. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Clause-② re-derived from the delivered diff as `no`: the new `lowerDatasetCompareDateRange` is exported from its source module for the conformance runner's relative import, but `index.ts` re-exports a closed named list that does not include it, so it is absent from `dist/index.d.ts`'s export statement and `'lowerDatasetCompareDateRange' in await import(entry)` is false. Presence in the bundle is not reachability — it greps twice in `dist/index.js`. ⇒ patch, not minor. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 56f349fa5febcdd64287140ac61ec567bda4baad && git checkout 56f349fa5febcdd64287140ac61ec567bda4baad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b b3adf9db56d18a36059959c6cc97b1fcf7af5c65 && git checkout -B drift-repro b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b && git merge --no-ff b3adf9db56d18a36059959c6cc97b1fcf7af5c65
node scripts/docs-audit/affected-docs.mjs --json b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b
|
…non-UTC limitation The changeset claimed `endExclusive` handling keeps "the ten calendar presets not a day too long", unqualified. My own out-of-scope measurement falsifies that outside UTC, and the changeset is the text an upgrading operator greps. The two causes are independent, and the measured rows separate them: for Asia/Shanghai the exclusive end was handled correctly (the window ends 2025-09-30, not 2025-10-01) and the extra day came from the START projecting early; for America/New_York the start was right and the END projected late. So `endExclusive` removes one cause of a day-too-long window and the UTC-day projection supplies another at the opposite bound. Narrow the clause to what the reading supports — the exclusive upper bound does not itself add a day to the projected window — and scope the worked example to the UTC calendar. Add the limitation as a third upgrade note with all three measured rows, naming the shape and pointing at the card the seat filed. Prose only: no code, test or behaviour change. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…he reference calendar, not UTC (objectstack-ai#18596) Fixes objectstack-ai#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 objectstack-ai#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 (objectstack-ai#3777 / objectstack-ai#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 (objectstack-ai#17015 / objectstack-ai#17124 / objectstack-ai#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>
Fixes #17973
Clause-②: no
What was wrong
DatasetExecutor.runCompareread the STRING arm ofdateRangeas[range, range]— the degenerate fallback PR #17015 removed from every other analytics face.parseUTCwas handed the preset NAME, so a declared, honoured member of the closed vocabulary was refused outright.Reproduced first, driven through the real executor on
b3b43b6ea(the card's2c87a48fline numbers had moved; the shape had not — the degenerate arm was at:1318):The severity is the FALSE diagnostic.
last_30_daysis exactly what the schema, the dashboard date filter and the docs tell an author to write, so "invalid date" sends them to check a date that is already correct — a repair that does not exist.What changed
Both arms now go through one face lowering,
lowerDatasetCompareDateRange, which calls the sharedresolveAnalyticsDateRangeStringfor the string arm — the same call the ObjectQL strategy, the native-SQL strategy, the draft-preview evaluator and driver-memory's cube face make. ⛔ No fourth spelling of the lowering was invented.The resolved window is then projected onto the comparison math's UTC calendar by
inclusiveUtcDayWindow, becauseshiftRangemeasurespreviousPeriodas whole days between two day-starts andalignedCompareBucketKeyreads the upper bound as the LAST bucket.endExclusiveis load-bearing there: the ten calendar presets report the first instant AFTER the window, so reading their bound as inclusive would make every one of them a day too long.That split — report the window the vocabulary resolved, keep the per-face calendar translation downstream — is the one
lowerPreviewDateRangealready makes when it leaves the bare-day widening in its own predicate.Timezone precedence is
buildQuery's, verbatim (selection.timezonethencontext.timezonethenUTC), so the comparison window resolves in the same calendar as the primary pass it is compared against.The array arm is untouched, bound for bound.
After:
runCompareis also registered as the fourth face in the shared conformance kit, as the card asks.On this path an out-of-vocabulary
dateRangeSTRING used to answerDATASET_INVALID; it now answers the ADR-0112ANALYTICS_DATE_RANGE_UNRECOGNIZED/ 400 envelope the other four faces already raise. One condition, one envelope. The changeset states it as a consumer-facing note. TheDATASET_INVALID "invalid date in dateRange"refusal for an ARRAY bound that is not a date is unchanged.Pins
The card asks for a pin that asserts the preset lowers-and-shifts, not merely that the string changed. Five cases, driven end to end through
DatasetExecutor.execute, expectations stated literally against the frozen clock and only cross-checked against the shared resolver — ⛔ never computed withshiftRange, which would restate the implementation and pass for any window at all:previousPeriodshifts by the window's own length;toThrow()— this face DID throw before the fix;⛔ No existing test pinned the defect as setup, so none was repaired, skipped or weakened. The two fixtures that assert
invalid date in dateRange(packages/rest/src/analytics-dataset-unlisted-refusal-envelope.test.ts:186andpackages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts:264) both drive the ARRAY arm and are unaffected — measured, not assumed.Reverse verification
The fix was committed first, then mutated back out on disk.
inclusiveUtcDayWindow(lowered)1 to 0, degenerate[td.dateRange as string, td.dateRange as string]0 to 1; blobe0f74dfto99f8cfb.../dataset-executor.jsby relative specifier, so vitest reads SOURCE. Going red without a build is itself the proof of that resolution path — a dist-resolved test would have stayed falsely green.lowerDatasetCompareDateRangeitself was untouched — which is precisely the "an exported helper conforms while the live path keeps its old fallback" gap, and precisely why the end-to-end block exists. The array-arm control and the arity pins also stayed green, as they must.e0f74df(equal to the HEAD blob),git diff HEADempty,git status --porcelainempty, anchors back to 1 and 0.Clause-② re-derivation
Re-derived from the DELIVERED diff, not from the claim's prediction.
lowerDatasetCompareDateRangeis exported from its source module so the conformance runner can import it relatively, butindex.tsre-exports a CLOSED named list from./dataset-executor.jsthat does not include it.shiftRangelowerPreviewDateRangedist/index.d.tsexport statementSymbol in await import(entry)dist/index.jsThe third row is the point: the symbol IS in the bundle and is NOT reachable. A grep would have answered
yes. The negative control is a symbol exported from its own source file, not re-exported byindex.ts, and predating this diff — it proves the method distinguishes the two.⇒ Clause-②: no, so the changeset is
patch.Verification
Gate denominator, reconciled against
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran, derived at3a1d131fa:Four of the 62 first returned exit 3, PREREQUISITE NOT MET (
check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) or timed out (check:query-options-erasure). ⛔ Not read as green: the full workspace closure was built (turbo run build, 72/72 successful) and all four re-run to a real exit 0.Tests:
@objectstack/service-analyticsfull suite — 111 files, 2398 tests, 0 failures.@objectstack/restanalytics door plus refusal envelope — 35 tests, 0 failures. Run because the behaviour change is observable through the REST door; the first attempt failed on an unbuiltdist, which is a resolution error and not a red gate, so service-analytics was built and it was re-run to a real verdict.@objectstack/service-analytics typecheck— exit 0, andtsc --listFilesconfirms the program really contains both the edited source and the edited test (1 and 1), with a negative control at 0.--format json. The invariance claim is read from eslint's own config —eslint.config.mjssets noparserOptions.projectand no typed rules, so type-aware linting is off and this diff cannot move the verdict on any untouched file. Repo-widepnpm lintis CI's run.Consumption-radius sweep
Swept by the RULE's consumers, not by the edited package. The changed rule is
runCompare's string-arm reading and its refusal envelope; its call chain isDatasetExecutor.execute, reached throughAnalyticsService.queryDataset.packages/services/service-analytics— full suite green.packages/rest— the only other package that drives the executor; both analytics fixtures green.packages/client— SDK over a mocked transport, never runs the executor; nocompareTofixture.examples/app-crm— asserts widget metadata shape only, with{current_quarter_start}filter tokens rather than adateRangepreset; no executor run.driver-memoryDST and timezone suites exercise the memory cube face, not this executor, and neither@objectstack/corenor driver-memory was touched.Acceptance notes
DatasetExecutor's compare math is UTC-calendar throughout (parseUTCreads a bare day as UTC midnight,toISODateemits a UTC day). For a non-UTCselection.timezonea preset's resolved bounds are therefore projected onto UTC day boundaries, which can make the comparison window a day longer than the org's own calendar would say. Pre-existing for the array arm and strictly better than the 400 it replaces, so ⛔ not widened into this PR.Generated by Claude Code