fix(driver-memory): an analytics time dimension buckets by its declared granularity (#16178) - #17206
fix(driver-memory): an analytics time dimension buckets by its declared granularity (#16178)#17206claude[bot] wants to merge 5 commits into
granularity (#16178)#17206Conversation
…d granularity `AnalyticsQuery.timeDimensions[].granularity` was accepted and never read, so a time dimension answered one group per distinct timestamp — one bar per row in a "new accounts by month" chart, under an ordinary 200 with no warning. The forward bucket labeller is hoisted into `@objectstack/core` as `bucketDateKey`, beside the inverse `bucketKeyToCalendarRange` and the `calendarPartsInTzOrUtc` primitive it already builds on. `@objectstack/objectql`'s `bucketDateValue` becomes a thin delegate with its export name and signature unchanged, so the two in-memory bucketing paths cannot label one instant differently. `driver-memory` folds by granularity between the `$match` half of its pipeline and its `$group`. The bucket key travels under a synthetic field rather than overwriting the row's own, so a member that is both a group key and a measure's aggregand still ranks instants in `max()` while grouping on the label. `second` / `minute` / `hour` are refused at compile with NOT_IMPLEMENTED/501 — the canonical key vocabulary defines no label for a sub-day bucket, and passing one through is the same defect under a new name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…e and its refusal The card's own measurement, with the control that makes it one: two rows on a single UTC day fold to one group under `granularity: 'day'` and stay two groups when nothing asks for a bucket. Beside it: the canonical output vocabulary for all five granularities (the week label is `YYYY-Www`), the reference-timezone fold across three zones, the `dateRange` window left undisturbed on the same entry, the NOT_IMPLEMENTED/501 refusal for the three sub-day intervals asserted on code and status, and the measure-over-the-same-member cell that the synthetic bucket field exists for. `@objectstack/core` gains cells for the labeller itself; `@objectstack/objectql` gains a pin that `bucketDateValue` cannot come apart from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…l's runtime string `check:doc-authoring` reds on an internal issue id inside customer-facing prose: an operator reading a 501 body has no tracker, no git log and no ADR to resolve it against. The anchor stays in the function's doc comment, where the reader who can resolve it is already looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 3 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 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 acaabae07c9d4873df79c92029319740503e8876 && git checkout acaabae07c9d4873df79c92029319740503e8876
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d61139f1baa9e874c4c8b8e22a80c61510945f55 f3961a58f75b19466779322746bc20f92e98b940 && git checkout -B drift-repro d61139f1baa9e874c4c8b8e22a80c61510945f55 && git merge --no-ff f3961a58f75b19466779322746bc20f92e98b940
node scripts/docs-audit/affected-docs.mjs --json d61139f1baa9e874c4c8b8e22a80c61510945f55
|
Seat rulings — and a correction to my own dispatch orderDelivery accepted. Three rulings, and one admission that belongs first.
|
Fixes #16178
Clause-②: yesAnalyticsQuery.timeDimensions[].granularitywas accepted bydriver-memory's analytics face and never read, so a time dimension answered one group per distinct timestamp — one bar per row in a "new accounts by month" chart, under an ordinary 200 with no warning anywhere.Reproduction, re-measured on this branch's own base
The card's anchors had moved (PR #17015 rewrote 198 lines of the file), so every anchor here was re-derived by symbol and the card's own controls re-run on
origin/mainfd5cff209:1328902ce)fd5cff209)granularityinmemory-analytics.tsdateRangetimeDimensionsMeasured through the public entry against the built package, two rows on one UTC calendar day (
2026-09-06T01:00:00Z,2026-09-06T23:00:00Z):granularity: 'day'2026-09-06granularity: 'hour'NOT_IMPLEMENTED/ 501The emitted pipeline was byte-identical across all three before the fix. That is the whole finding: the request was accepted, nothing warned, and the key was inert.
The order fenced the change to
packages/drivers/driver-memory/src/memory-analytics.tsplus its tests and a changeset. This PR also editspackages/coreandpackages/objectql. That is not a drift; the fence as written has no executable route inside it.The recorded ruling on the card (director seat, decision batch #91, comment 5583991002) ruled Q1: A and refused the alternatives by name:
objectqlfor the labeller;service-analytics'bucketDate(the non-canonical week label);Inside the fence, the only way to bucket a
weekis to hand-copy the ISO-week rule intodriver-memory, which is exactly the refused D and exactly the divergencecheckDateBucketParityexists to catch. So the fence, taken literally, mandates a ruled-out route. The ruling's own execution note says "one PR spanningcore,objectql,driver-memory", and an earlier claim on the card enumerated that same surface.What the fence's one explicit prohibition asked for is honoured in full:
packages/spec/**is untouched.granularityis already declared there, the spec-side narrowing ofTimeUpdateIntervalis filed separately as adomain:speccard under ADR-0049 by the ruling itself, and nothing here crosses that lane.driver-memoryalready declared@objectstack/coreas a runtime dependency, so route A adds no dependency edge.One ruled item is deliberately deferred and not silently dropped: the ruling also asked for ADR-0053's implementation-map row to gain
memory-analytics.tsin this PR.docs/adr/**is a governed surface, and adding it would make this diff governed whole — permanently removing it from the merge queue and reserving the landing to aGOVERNED_APPROVERSreview, a landing-path escalation the dispatch order did not contemplate. It is a one-line documentation row that lands trivially on its own. Say the word and it goes in this PR instead.What it does
@objectstack/core.bucketDateKey(value, granularity, timezone)now sits beside the inversebucketKeyToCalendarRange, theBucketGranularitytype and thecalendarPartsInTzOrUtcprimitive it builds on — all of which were already there.BUCKET_GRANULARITIESandisBucketGranularityname the five granularities that have a canonical key, so a face that must refuse the other three quotes the accepted set instead of hand-listing it. The privateisoWeekLabelUtcand the new labeller now share one statement of the week rule rather than two.@objectstack/objectql'sbucketDateValueis a delegate — export name, signature and answers unchanged. The unreachabledefaultarm's echo is preserved inbucketDateKeyso an off-type JS caller gets the answer it always got.driver-memoryfolds by granularity before its$group. The pipeline is cut at that stage: the$matchhalf still runs in the driver (where the rows and the tenancy guard are), the bucket keys are written onto the selected rows, and the grouping half runs over those. mingo has no expression that produces2026-Q3or2026-W36, and building one out of$isoWeekand$concatwould be the second dialect this repair exists to prevent.created_atin place would leavemax(created_at)ranking bucket labels. Pinned.second/minute/hourare refused at compile withNOT_IMPLEMENTED/ 501 — the classrefusePerAggregationFilteralready uses for the same reason: the query is spelled correctly, the spec declares the value, and it is this backend that compiles nothing for it. Asserted oncodeandstatus, never on message text.How bucketing interacts with the
timezonerepair (#16042)Measured, not assumed. The fold takes
AnalyticsQuery.timezone— the same reference zoneparseDateRangeStringresolves adateRangepreset against — so the window that selects the rows and the bucket that folds them agree on where a calendar day starts. The same two rows:timezone2026-09-06UTC2026-09-06America/New_York2026-09-05,2026-09-06Asia/Tokyo2026-09-06,2026-09-07⇒ UTC is demonstrably not the only case, and the
dateRangewindow is not regressed: both keys on one entry still select by the window's own published semantics and fold what survives. The preset arm and the granularity resolve against onequery.timezone.Coverage
@objectstack/driver-memory@objectstack/core@objectstack/objectqltypecheckclean on all three. All figures taken atf3961a58f7, after theorigin/mainmerge and a full workspace build.Ablation — both legs, proven on disk and through the built artifact
Leg A — restore the defect in the driver. Keying the
$groupon the raw field path again turns 9 of the 14 new cells red. The 5 that stay green are exactly the ones that should: the no-granularity control and the four refusal cells. A blanket failure would have proved much less than this discrimination does.Leg B — mutate the labeller in
core, throughdist. A marker injected intobucketDateKey's week branch,@objectstack/corerebuilt, andablation-dist-preflightconfirming the marker reached 2 built files — thendriver-memorygoes red on exactly its week cell andobjectqlon 3. That is the proof that the driver consumes core's built labeller rather than a stale copy, which no source-only ablation could establish.Both restore legs verified by
git diff HEADempty andgit hash-objectequal to the HEAD blob; leg B's restore additionally re-verified withablation-dist-preflight --absent, whole-tree clean. Neither is a permanent test file.Semver
minoron all three,BREAKINGbanner, ADR-0087not-required (no-migration-prescription).@objectstack/core—minor. Three new public-entry exports. Unambiguous under the house rules.@objectstack/driver-memory—minor. Two events, and neither is apatch. The house rule "repairing an implementation that silently violated its own already-published declared type" would grade the bucketing halfpatch, but that rule does not reach the second half: a value the verb accepted is now refused, which is an accept-set narrowing, not a widening and not an envelope on an existing refusal. And the bucketing half changes the answers an accepted request returns. "Already accepted the field and ignored it" is a different event from "newly accepts it", and this is a third thing again: it now honours it, and refuses the sub-set it cannot honour.minorwith the banner; ⛔majorrefused per the order.@objectstack/objectql—minor. Its own answers do not move by a byte — pinned across granularity, timezone and input form rather than asserted — so on the level axis alone this is apatch. It is gradedminorunder the launch-window lockstep convention its two sibling changesets in this cluster invoke by name ("during the window the bump level is not the carrier, this banner and the disposition above are"), and because the ruling graded all three that way.The card's
priority:p2played no part in any of this; they are unrelated axes.Gates
61 families derived by
scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no path argument) and reconciled with--ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN. All 61 exit 0 on the merged head.Two needed a prerequisite before they meant anything and were not reported as passes until they had it:
check:dual-build-cjs-loadsandcheck:type-check-debtboth exited 3 —PREREQUISITE NOT MET— against a partially built tree; a fullpnpm buildwas run and both then exited 0.check-engine-split-ratiorefused on a shallow clone; the clone was deepened as the gate's own remedy line prescribes, and it then measured 98.1%, a repo-wide metric this diff cannot move.One gate found a real defect in the first draft and it is fixed:
check:doc-authoringreds on an internal tracker id inside customer-facing prose, and the refusal message carried one. The anchor moved to the function's doc comment, where a reader who can resolve it is already looking.Acceptance notes
Two boundaries measured on this branch, neither filed, both put in front of the contract review rather than decided here:
The explicit-array
dateRangearm andgranularityanswer in different frames. Withtimezone: 'America/New_York',dateRange: ['2026-09-05', '2026-09-05']andgranularity: 'day', a row at2026-09-05T02:00:00Zis selected by the window (which compares instants, perdriver-memoryanalyticsdateRangeupper bound is INCLUSIVE for a full-timestamp end, so the first instant of tomorrow counts as today #16179's explicit ruling that a caller's window is never narrowed) and then labelled2026-09-04(the NY calendar day, per ADR-0053). So a query windowed to one day can answer under a bucket labelled the day before. Both halves are separately ruled; the seam between them is newly visible because the fold is now timezone-aware. This is a contract question for the Clause-② review, not a bug to fix unilaterally.A
granularityon a member that is not indimensionsis still inert. Measured:timeDimensions: [{dimension, granularity: 'day'}]with nodimensionsanswers one total. Cube.js semantics would make the time dimension a result column of its own; nothing inAnalyticsQuery's declaration says it groups, so this is a compatibility gap rather than a contract violation. Folding only group keys is also the safe half — it is what keeps a measure over the same member ranking instants.⛔ Left on this PR as required: the
needs:contract-reviewlabel, draft status, and no ready flip, enqueue, auto-merge or merge.Generated by Claude Code