fix(service-analytics): a date-bucket key's two readers match core's writer for a year below 1000, and the draft preview keys a week by its ISO label (#20867) - #20971
Conversation
…ter for a year below 1000 (red) formatDateBucket labels every key bucketDateKey writes as written (0050, 0050-06, 0050-06-15), and bucketDate delegates to bucketDateKey, so a draft preview keys 0050-06-15 as the published path does, the ISO week label included. A 2026 control on each. Red at this commit: the fix follows. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…r for a year below 1000, and the preview keys a week by its ISO label formatDateBucket labels a key bucketDateKey writes as written, recognised by core's bucketKeyToCalendarRange (0050 was labelled 1970, 0050-06 became 50-06), and relabels a raw value through bucketDateKey in UTC. bucketDate delegates to bucketDateKey: no second padding, and the draft preview now writes the runtime's ISO week label, so a drafted chart keys a row as the published path does. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 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 — 10 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 fa02d5f3ab4b27c4d6803d99756680f5cea0c222 && git checkout fa02d5f3ab4b27c4d6803d99756680f5cea0c222
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 25ba591849c1aaad8956e0dd21423189c7aed620 && git checkout -B drift-repro 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 && git merge --no-ff 25ba591849c1aaad8956e0dd21423189c7aed620
node scripts/docs-audit/affected-docs.mjs --json 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 |
Contract reviewServed-tier: Inputs, and nothing else: card #20867 (its body and all four comments: triage ① Derived judgments
Nothing in the diff is judged wrong. ② Semver level
③ Boundary flags
Check-runs on the head, latest run per name, read at 2026-09-30T23:55Z: 34 names, every run on head Implemented-by: VERDICT: PASS |
Fixes #20867
Clause-②: no
What changes
Triage direction 5912878801, as ruled: the two
service-analyticsreaders of a date-bucket key match@objectstack/core's one writer,bucketDateKey. Nothing inpackages/core,packages/specorpackages/objectqlmoves.dimension-labels.tsformatDateBucket(the dimension labelsqueryDatasetresolves after grouping). A string that is a key the writer writes at the stated granularity is returned as written. What counts as one is decided by core's one reader of those keys,bucketKeyToCalendarRange, not by a second pattern here. A raw value (epoch ms, ISO text,Date) is relabelled bybucketDateKeyitself, in UTC, so no label here spells a year of its own. Aweekor unstated granularity keeps labelling a raw value as its owndaykey, as before. The numeric-year branch (a driver answering2026as a number) is unchanged.preview-evaluator.tsbucketDate(the draft preview,queryDatasetwithpreviewDrafts). It is nowbucketDateKey(value, granularity, timezone), typed with core'sBucketGranularity. No second padding. Its caller passes the cube dimension's single granularity as typed, where it used to passString(...)of it.bucketDateKey,bucketKeyToCalendarRange,isBucketGranularityandBucketGranularityare exports of@objectstack/core(src/utils/datetime.ts, re-exported bysrc/index.ts), a runtime dependency of this package thatdataset-executor.tsandanalytics-service.tsalready import them from.Before and after
Base
f6ccca4a44, head25ba591849. Function level,TZ=UTC, read with a scratchtsxscript over the package's own sources and core's builtdist. The reference column isbucketDateKey.0050formatDateBucket(key, 'year')0050197000500050-06formatDateBucket(key, 'month')0050-0650-060050-060050-06-15formatDateBucket(key, 'day')0050-06-1550-06-150050-06-150999formatDateBucket(key, 'year')0999197009990050-Q2,0050-W24,0049-W52formatDateBucket0050-06-15T10:00:00.000ZformatDateBucket(raw, year / quarter / month / day)0050·0050-Q2·0050-06·0050-06-1550·50-Q2·50-06·50-06-150050·0050-Q2·0050-06·0050-06-152026keys and raw values (control)formatDateBucket0050-06-15T10:00:00.000ZbucketDateyear / quarter / month / week / day0050·0050-Q2·0050-06·0050-W24·0050-06-1550·50-Q2·50-06·0050-06-13·50-06-150050-01-01T10:00:00.000ZbucketDateweek0049-W520049-12-270049-W520999-06-15T10:00:00.000ZbucketDateyear / month0999·0999-06999·999-062026-06-15T10:00:00.000Z(control)bucketDateweek2026-W252026-06-152026-W252026-06-15T10:00:00.000Z(control)bucketDateyear / quarter / month / day2026·2026-Q2·2026-06·2026-06-152026-06-15T10Z/0050-06-15T10ZbucketDate, every granularitynull(the empty bucket)Dateof0050-06-15T10ZbucketDateyear / day0050·0050-06-151950·1950-06-15And at the
queryDatasetlevel, over the same three rows (0050-06-15,0050-06-15T10:00:00.000Z,2026-06-15). The published leg is the ObjectQL strategy over the engine's in-memory grouping (@objectstack/objectqlapplyInMemoryAggregation) followed by the dimension labels. The preview leg ispreviewDraftsover the same rows as the seed draft.1970:2 ·2026:150:2 ·2026:10050:2 ·2026:10050-Q2:2 ·2026-Q2:150-Q2:2 ·2026-Q2:10050-Q2:2 ·2026-Q2:150-06:2 ·2026-06:150-06:2 ·2026-06:10050-06:2 ·2026-06:10050-W24:2 ·2026-W25:10050-06-13:2 ·2026-06-15:10050-W24:2 ·2026-W25:150-06-15:2 ·2026-06-15:150-06-15:2 ·2026-06-15:10050-06-15:2 ·2026-06-15:1The preview's week key (triage's third bullet): measured, and adopted
YYYY-MM-DD(2026-06-15) where the runtime writes the ISO week label (2026-W25), for every year, the 2026 control included.bucketDatedelegating tobucketDateKeywrites the ISO week label. The difference is not kept.DatasetExecutor, whosecompareToalignment reads a key throughbucketKeyToCalendarRange, and that reader answersnullfor a Monday date atweek. Measured with one row on2026-06-15and one on2025-06-16, apreviousYearcomparison over the window2026-06-15..2026-06-21:2026-W25, current 1, compare 1;2026-06-15(1 / 0) and2025-06-16(0 / 1), the comparison appended as a foreign row;2026-W25(1 / 1), as published.week-key-year-below-100.test.ts, [[finding] outside calendar-day.ts, a year from 0001 to 0099 is still read as 1900..1999: Date.UTC's two-digit-year remap in core's datetime and bucket helpers, filter-tokens and the REST import's datetime cell #20599]) pinned the Monday date. They now read the ISO label for the same instants, and the Monday each week starts on stays in a comment.Clause-② measured
no, with no arm. The package's public entry (src/index.ts,exports.only) exports neither function, so no export is added, removed or retyped. What changes is published output: the labelsresolveDimensionLabels/queryDatasetgive a key or a raw value in 0001..0999, and the preview's keys. Each now equals what the published path answers or the writer writes. No input that was answered before is refused now. The public door parsestimeDimensions[].granularityanddateGranularityagainstDatasetSelectionSchema(read at schema level:hourrefused,weekaccepted), sobucketDate's narrowed parameter type admits every value that door lets through. The line parses asnounderscripts/pm/clause2-line.mjsreadClause2Line.Pins
src/__tests__/bucket-key-readers-four-digit-year.test.ts(new), every expected key spelled literally, 0050 / 0050-01-01 / 0999 with a 2026 control:0050,0050-06and0050-06-15label as written, and so does every key the writer gives each instant at each granularity;bucketDateequals the writer for ISO text, epoch ms and aDate, and inAsia/Shanghai(0049-W52in UTC is0050-W01there);0050-06-15as the published path does, at every granularity, with the 2026 control.Committed red first (
748c4bd5b9): 38 failed / 28 passed of 66 across the new file and the two updated preview week pins. The fix is25ba591849.Ablation
At
25ba591849, one locked script. The subjects are imported fromsrc/by relative path, so no build is involved. Every mutation went throughscripts/ablation-replace.mjs(anchor 1 to 0, blob changed) and was restored by it: the blob equals HEAD's (dimension-labels.ts50578e3e0edc,preview-evaluator.ts5eafd898fefc) andgit diff HEADis empty. Pins run: the new file andweek-key-year-below-100.test.ts, 66 tests.f6ccca4a44(blobs682193a14bc0/522ef30f91fbon disk)formatDateBucket's key recognition deleted0050/0999year key reads1970(the key pin, three every-granularity pins, and the published leg atyear); the 2026-only cases stay greenbucketDatestrips the year's leading zerosbucketDatecontrol greenbucketDatekeys a week as a dayAfter all legs: both blobs equal HEAD's,
git status --porcelainempty, 66 / 66 green.Verification (head
25ba591849)--maxWorkers=2):@objectstack/service-analyticsvitest run, 147 files / 3398 tests passed.typecheck(tsc --noEmit) exits 0, andtsc --listFileslists both pin files, so the typecheck covers them.turbo run build --filter='@objectstack/service-analytics^...' --concurrency=1(14 / 14), then the package itself (exit 0;check-dts-emitted2 / 2).dist/index.d.tsnames neither function.node scripts/pm/dispatch-gates.mjs --commandsre-derived at this head: 61 commands, identical to the dispatch-time list. All 61 ran in one locked sequential script, with the four roster families the derivation marks (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity). 63 exited 0 and 2 exited 3.--ran, with each exit code recorded: 61 derived, 59 run, 2 NOT MEASURED, 0 unrun.check:dual-build-cjs-loadsandcheck:type-check-debt, both PREREQUISITE NOT MET (exit 3). They read every workspace package's built output, and this run built only the tested closure, as the dispatch's resource rule requires. CI's lint job builds the whole closure before them.check-changeset-no-majorexited 0 ("nomajorbump"). Its Clause-② level axis reads the PR payload, so it had no input locally.eslint --no-inline-config --format jsonover the 4 changed.tsfiles. The JSON reports 4 files, 0 errors, 0 warnings, and none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project, stated in the config itself), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Acceptance notes
POST /analytics/dataset/query. The readings above are at function level and atqueryDatasetlevel, over the engine's in-memory grouping. No SQL driver ran; SQLite and PostgreSQL already pad their keys (measured in PR fix(core,service-analytics): a date-bucket key spells its year with four digits, and the reader reads the week key the writer writes (#20760) #20865). The door's refusal ofhouris read fromDatasetSelectionSchema.safeParse, not from the route.formatDateBucketstill reads a NUMERIC year below 1000 (50underyear) as epoch milliseconds (1970). No named producer answers a numeric year key: SQLitestrftime, MySQLdate_format, PostgreSQLto_charand MongoDB$dateToStringall answer text, and the text key0050is now recognised.weekstill labels as its own day key (YYYY-MM-DD). A week bucket's key is the ISO label on every producer, and that key is recognised and kept.bucketDategiven a granularity outside the five now answersbucketDateKey's echo of the raw value, where it answered the day key. Its type admits only the five, andDatasetSelectionSchemarefuseshour(schema-level reading), so no public path reaches it. It is also the answer the engine's in-memory grouping gives, sincebucketDateValuedelegates to the same writer./exportwrites adate/datetimecell with a year below 1000 unpadded (0500-01-01→500-01-01), so the export does not re-import #20602 (/export) remains its own card and is not addressed here.Generated by Claude Code