Skip to content

Commit 12b5aec

Browse files
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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(service-analytics): a windowed dataset answer reports its window in `drillRanges`, so a drill list stays inside the picked period
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A dataset query whose selection carries `timeDimensions[].dateRange` (a report's picked period) counts only that period's records, but its answer's `drillRanges` covered only the date dimensions it groups by. So a report run on "this quarter" and grouped by stage drilled to a list of every period's deals, and a month bucket cut by `last_30_days` drilled the whole month.
12+
13+
- **The window rides `drillRanges`.** A windowed dimension that is not grouped gets an entry on every row: the `[gte, lt)` the query filtered by. 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, so an edge bucket drills only its in-window records. Drill hosts already filter by every `drillRanges` entry, by its `field`, so they need no change.
14+
- **One window, not a second resolver.** The bounds come from the query's own steps: the preset resolved in the reference time zone, an explicit `[a, b]` as written, the shared whole-day lowering (a bare-day end runs to the start of the next day), and the engine's temporal storage form: ISO instants for a `datetime` field, `YYYY-MM-DD` days for a `date` field.
15+
- **No entry when there is no exact spelling.** An explicit window whose end is an instant (compared with `<=`), or whose end is the last supported day (no upper bound), gets no entry, and the drill stays as wide as before. So does a field whose type is unknown under a non-UTC time zone, as for buckets.
16+
- **Spec.** The `drillRanges` docblock on `AnalyticsResult` and its `.describe` text on the dataset answer schema say what the key now carries. No key is added.

‎content/docs/references/api/analytics.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ const result = AnalyticsEndpoint.parse(data);
158158
| **dimensionFields** | `Record<string, string>` | optional | Drill-through sidecar: each equality-drillable dimension the answer is grouped by, dimension name to that dataset dimension's own `field` (a base-object field or a `relationship.field` path). Set only on a dataset answer (`POST /analytics/dataset/query`) that has rows and groups by at least one dimension with a `field` that is not `type: 'date'`; a date dimension is never listed (see `drillRanges`). Absent on a cube query answer. |
159159
| **drillRawRows** | `Record<string, any>[]` | optional | Drill-through sidecar, aligned to `rows` by index: each `dimensionFields` dimension name to the stored value that row was grouped by (a select option's value, a lookup's record id), captured before label resolution rewrites the row to its display label. An exact-match drill filter is built from these, not from the labels in `rows`. Set exactly when `dimensionFields` is. |
160160
| **drillRawTotals** | `Record<string, any>[][]` | optional | Drill-through sidecar for `totals`: entry `[i][j]` aligns to `totals[i].rows[j]` and holds the stored value of the `dimensionFields` dimensions that grouping groups by, so the grand-total grouping yields an empty map per row. Set only when `dimensionFields` is and the answer carries at least one `totals` grouping. |
161-
| **drillRanges** | `Record<string, { field: string; gte: string; lt: string }>[]` | optional | Drill-through sidecar for time buckets, aligned to `rows` by index: date dimension name to the half-open range `[gte, lt)` that row's bucket covers. Bounds are `YYYY-MM-DD` calendar days, or ISO-8601 instants at midnight in the reference timezone when the source field is `datetime`; a row whose bucket value is null gets no entry for that dimension. Set only on a dataset answer that has rows and groups by at least one `type: 'date'` dimension with a `field` and a granularity; independent of `dimensionFields`. Absent on a cube query answer. |
161+
| **drillRanges** | `Record<string, { field: string; gte: string; lt: string }>[]` | optional | Drill-through sidecar for date ranges, aligned to `rows` by index: date dimension name to the half-open range `[gte, lt)` that row's records fall in on that dimension's field. A time bucket gets the span it covers; a `timeDimensions[].dateRange` window gets the window the query filtered by on every row, or narrows the bucket entry on the same field to the part inside the window. Bounds are `YYYY-MM-DD` calendar days, or ISO-8601 instants when the source field is `datetime` (a bucket's at midnight in the reference timezone); a row whose bucket value is null gets no bucket entry, and a window whose end is an instant or the last supported day gets none. Set only on a dataset answer that has rows and groups by at least one `type: 'date'` dimension with a `field` and a granularity, or carries a `dateRange` on a dimension with a `field`; independent of `dimensionFields`. Absent on a cube query answer. |
162162

163163

164164
---

0 commit comments

Comments
 (0)