Repository navigation
Commit 12b5aec
fix(service-analytics): a windowed dataset answer reports its window in drillRanges (#27169)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XNhm8vEAcYhLXyH8smEnuHt)_
Fixes #27074
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
-
**改了什么**:报表选了时间段(`selection.timeDimensions[].dateRange`,如“本季度”)后,数据集查询的回答在已有的
`drillRanges` 里带上这个时间段的边界:没按该日期分组时每行多一条 `{ field, gte, lt
}`;按同一字段分桶时,首尾两个桶的范围收窄到时间段以内。没有新增键。
- **为什么改**:报表按阶段分组、选“本季度”后,点阶段下钻,列表里出现所有季度的商机,柱子上的数和列表对不上;`last_30_days`
的首尾月份下钻也会列出整月。
-
**风险与回滚**:低。只改回答里的下钻元数据,不改查询本身过滤什么;边界直接复用查询自己的解析(预设按请求时区、显式区间原样、共享的整日规则、引擎的存储形式),不在浏览器或服务端另写一套。回滚即
revert。
-
**先看**:`packages/services/service-analytics/src/drill-window.ts`;`analytics-service.ts`
的 `drillRanges`
块(窗口与桶按字段合并);`src/__tests__/dataset-drill-window.test.ts` 的真实引擎用例。
- **两分钟验证**:`pnpm --filter @objectstack/service-analytics exec vitest
run --maxWorkers=2 src/__tests__/dataset-drill-window.test.ts`,预期 21/21
通过;在 `main` 上同一文件 18 条失败。
- **需要你做的**:无,席位落地。
## What changes
A dataset query whose selection carries `timeDimensions[].dateRange` now
reports that window in the answer's existing `drillRanges`, so a drill
list filters by exactly the bounds the query filtered by. Nothing under
`ui/`, and nothing the query filters changes.
- **An ungrouped windowed dimension** (a report grouped by `stage`,
windowed on `close_date`) gets `close_date → { field, gte, lt }` on
every row.
- **A windowed field that a bucket already bounds** narrows that
bucket's entry to the part inside the window, and adds no entry of its
own. Merging is by FIELD, not by dimension name: a drill filter keys by
field, so a second entry on the same field (a `close_quarter` bucket
over `close_date`, windowed through the `close_date` dimension) would
have replaced the bucket's bound instead of meeting it.
- **The bounds are the query's own** (`drill-window.ts`, no second
lowering): `lowerDatasetCompareDateRange`, this package's registered
face in the shared `dateRange` conformance kit (a preset through
`resolveAnalyticsDateRangeString` in the request's reference zone, an
explicit `[a, b]` through `explicitDateRangeWindow`); the `{ $gte, $lt
}` / `{ $gte, $lte }` pair both strategies build, through
`normalizeDateRangeWindow` (the shared whole-day rule turns a bare-day
`$lte` into `$lt` the next day); then core's `temporalStorageForm`, the
rule the engine reads each temporal comparand through. `{token}`
placeholders resolve with `resolveFilterTokens`, as the executor
resolves them. The rule for which fields get an entry (`datetime` →
instants, `date` → days, unknown type only under UTC) is the one the
bucket ranges already used, now a shared `instantOf` in that block.
- **No entry when there is no exact half-open spelling:** an explicit
window whose end is an instant (the query compares `<=` it) or the last
supported day (no upper bound). The drill then stays as wide as before.
- **Spec wording only:** the `drillRanges` docblock on `AnalyticsResult`
(`packages/spec/src/contracts/analytics-service.ts`) and its `.describe`
text on the dataset answer schema
(`packages/spec/src/api/analytics.zod.ts`) say what the key now carries;
the generated `content/docs/references/api/analytics.mdx` row is
regenerated. No key, type or export added.
### Why `drillRanges` carries it with no ui edit (seat lean, measured)
Every ui drill path hands the row's `drillRanges[i]` to
`buildDatasetDrillFilter`
(`ui/packages/core/src/utils/dataset-format.ts`), which iterates
`Object.values(rawRanges)` and writes `{ [r.field]: { $gte: r.gte, $lt:
r.lt } }` for each entry whatever its key: the report row drill and
pivot cell drill (`DatasetReportRenderer.tsx`) and the dashboard widget
(`DatasetWidget.tsx`, which also treats a non-empty `drillRanges` as
drillable). The real-engine half of the new test runs exactly that
filter shape against `engine.find` and lists what each row counted. So
the reach closes with no ui change, and an existing reader sees only
more entries in a key it already reads (`Clause-②: yes (widening)`,
`Metadata-protocol: none`, `patch`).
## Tests
`packages/services/service-analytics/src/__tests__/dataset-drill-window.test.ts`,
21 cases, clock frozen at `2026-09-09T12:34:56.789Z`.
- **Unit, through `queryDataset` over a recorded engine aggregate
(ObjectQL face):** stage × `this_quarter` on a `datetime` field in
`Asia/Shanghai` answers `close_date → { gte: '2026-06-30T16:00:00.000Z',
lt: '2026-09-30T16:00:00.000Z' }` on every row, and the test reads the
very same `{ $gte, $lt }` off the filter the strategy handed the engine.
Month buckets × `last_30_days` are narrowed at both edges with the
middle untouched (Shanghai `datetime`, and an explicit `[2026-07-15,
2026-09-14]` on a `date` field). A window through `close_date` over a
`close_quarter` bucket on the same field narrows that entry and adds
none. An explicit bare-day window on `datetime` runs to the next UTC
midnight; a `{month_start}`..`{today}` token window resolves as the
executor resolves it; an instant end and a `9999-12-31` end get no
entry.
- **Controls:** no `dateRange` (equality-only grouping answers no
`drillRanges`; a granularity-only entry keeps its bucket range); a
`date` field serialises calendar days; UTC resolves at UTC midnight; an
unknown field type under a non-UTC zone gets no entry; `compareTo`
computes `cnt__compare` as before and the row drills the primary window.
- **Real engine (SQLite through `AnalyticsServicePlugin`'s own
composition, native and ObjectQL strategies):** ten deals either side of
Q3's edges in Shanghai and UTC. For each row, the drill filter the ui
builds (`{ field: { $gte, $lt } }` per entry, ANDed with the row's raw
grouped value) is run through `engine.find`, and the ids it lists must
number exactly what the row counted: stage × `this_quarter` on
`datetime` (Shanghai), stage × an explicit bare-day window on `datetime`
(Shanghai), month × `last_30_days` (Shanghai), stage × `this_quarter` on
`date` (UTC), and stage × `this_quarter` on `date` (Shanghai, parity
only; see Acceptance notes).
**Red first:** with `analytics-service.ts` restored to `origin/main`
`6845035c` and `drill-window.ts` removed (tree only, restored after with
`git diff HEAD` empty and the blob hash equal), the file reads 18 failed
/ 3 passed; the three that pass are the base-true controls (no
`dateRange`, the no-spelling ends, the unknown type). Green at head:
21/21.
**Ablations** (`scripts/ablation-replace.mjs`, restore proven against
the HEAD blob each time; this package's tests import its `src`, so no
build is in the path):
1. Delete `if (w.gte > r.gte) r.gte = w.gte;` (the window's lower edge
stops narrowing a bucket): 4 red, the two month-edge unit cases and the
month × `last_30_days` real-engine case on both strategies.
2. Spell every bound as `datetime` (`temporalStorageForm(v,
'datetime')`): 4 red, every `date`-field unit case. The real-engine
`date` cases stay green under it, as expected: the engine reads an
instant comparand on a `date` column through the same storage rule.
## Acceptance notes
- **A preset on a `date` field east of UTC filters the wrong days
(finding, not changed here).** The query compares a `date` column with
the preset's zone-midnight instants read as their leading UTC day, so
`this_quarter` in `Asia/Shanghai` counts 2026-06-30 and drops 2026-09-30
(measured on SQLite, both strategies; west of UTC the leading day is the
right one). The drill reports that same storage-form window, so the list
still shows what the row counted, and the real-engine case pins parity
only. Which days the query should count is reported to the seat as a
separate defect.
- **No exact spelling, no entry:** an explicit window whose end is an
instant (compared `<=`), or `9999-12-31` (no upper bound), leaves the
drill as wide as it was. No measured producer sends either: the report
chip sends a preset name or a `[from, to]` of bare days.
- **Explicit bare-day windows on `datetime` are cut at UTC midnight**
(ADR-0053 D-D), so the drill reports `T00:00:00.000Z` bounds even under
Shanghai. That is what the query filters by; whether the explicit arm
should follow the reference zone is ADR-0053's recorded open seam, not
this card.
- **Totals rows** carry no range companion (`drillRawTotals` has
equality values only), as before; no ui path reads `drillRawTotals`
today.
- **Midnight race:** the drill bounds resolve a preset with their own
`new Date()`, as each strategy does for the query. A request straddling
the reference zone's midnight could resolve the two a day apart.
- **Overlap:** #27109 (#26893, the empty time-bucket fill) landed while
this was in flight; `origin/main` is merged in (clean) and every suite
above re-ran on the merged head. Both in-window months of the month ×
`last_30_days` real-engine case hold a deal, so its rows are the same
with or without that fill. #26739 (measure polarity, open) edits other
hunks of `analytics-service.ts` and the two spec files.
## Verification
All at head `b807ecc5` (the branch merged with `origin/main` after
#27109 landed), after a full `pnpm build` (VERDICT command-exit 0),
unless a line says otherwise.
<details><summary>Package suites</summary>
- `@objectstack/service-analytics` `pnpm test`: 193 files, 4689 passed /
282 skipped; `pnpm typecheck` exit 0 (it type-checks the test files).
VERDICT command-exit 0.
- `dataset-drill-window.test.ts`: 21/21. Red first at `6845035c`
(production files restored, test file kept): 18 failed / 3 passed.
- `@objectstack/spec` `src/api/analytics.test.ts` +
`src/contracts/analytics-service.test.ts`: 43/43.
- Downstream, `@objectstack/rest`: every test file that posts a dataset
query (31 files), 1140 passed / 40 skipped. `@objectstack/dogfood`
(skips PR CI): `temporal-storage-e2e.dogfood.test.ts` +
`analytics-timezone.dogfood.test.ts`, 10/10.
- `eslint --no-inline-config packages/services/service-analytics/src
packages/spec/src/api/analytics.zod.ts
packages/spec/src/contracts/analytics-service.ts`: exit 0.
</details>
<details><summary>Derived gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `b807ecc5`: 107 families. Every one run,
each exit code captured before any pipe: 107/107 exit 0.
- `--ran` reconciliation: `✓ dispatch-gates --ran: 107 derived
famil(ies) accounted for — 107 run, 0 NOT-MEASURED`.
- `check-plugin-teardown-shape --self-test` needs its fixture commit in
a shallow clone: fetched with `git fetch --depth=1 origin 621a487…`
before the run.
</details>
Not run locally: `test:repo` (the diff touches no repo-project test;
CI's Test Core runs it); the full dogfood battery (no `!` in the title
and no `(narrowing)` arm).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5msvniAz5h2mP2QABA)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e5b5c5b commit 12b5aec
7 files changed
Lines changed: 622 additions & 29 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- services/service-analytics/src
- __tests__
- spec/src
- api
- contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
161 | | - | |
| 161 | + | |
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
| |||
0 commit comments